ok/jj
1
0
Fork 0
forked from mirrors/jj
jj/docs/design/tracking-branches.md
Yuya Nishihara e0965c4533 git: on push, update jj's view of remote branches without using import_refs()
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.
2023-10-17 15:06:03 +09:00

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