# 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