Archived
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c9932932bb | ||
|
|
b205576759 | ||
|
|
72500353cd | ||
|
|
fbd233f58d | ||
|
|
99f1ba2bec | ||
|
|
651b0c2e69 | ||
|
|
2aeebfe3b1 |
@@ -29,8 +29,9 @@ the questions you actually have:
|
|||||||
seconds. It never fetches on its own: the detail pane shows when a
|
seconds. It never fetches on its own: the detail pane shows when a
|
||||||
repository was last fetched, `f` fetches everything.
|
repository was last fetched, `f` fetches everything.
|
||||||
- **Fix it from the same screen.** Restore a repository to exactly what git
|
- **Fix it from the same screen.** Restore a repository to exactly what git
|
||||||
records (`x`), or switch it to another branch (`b`). Both ask first if work
|
records (`x`), switch it to another branch (`b`), or move a pin that is
|
||||||
would be lost.
|
behind to the tip of its branch (`u`). They ask first if work would be
|
||||||
|
lost.
|
||||||
- **Built for dozens to hundreds of submodules.** Each status loads
|
- **Built for dozens to hundreds of submodules.** Each status loads
|
||||||
asynchronously, so the UI never freezes.
|
asynchronously, so the UI never freezes.
|
||||||
|
|
||||||
@@ -103,8 +104,10 @@ clipboard, ready for `cd <paste>`. It works over SSH too (OSC 52; in tmux,
|
|||||||
Select a submodule (or the header for the whole superproject) and press `x`.
|
Select a submodule (or the header for the whole superproject) and press `x`.
|
||||||
Its worktree becomes exactly what git records, and so do all submodules
|
Its worktree becomes exactly what git records, and so do all submodules
|
||||||
below it: changes are reset, untracked files removed, merges and rebases
|
below it: changes are reset, untracked files removed, merges and rebases
|
||||||
ended, missing submodules cloned. Staged pins are kept. If something would be
|
ended, missing submodules cloned. Staged pins are kept. A submodule whose pin
|
||||||
lost, you are asked first:
|
is a branch tip ends on that branch instead of a detached HEAD (a missing
|
||||||
|
local branch is created from the remote one; no branch is ever moved). If
|
||||||
|
something would be lost, you are asked first:
|
||||||
|
|
||||||
```
|
```
|
||||||
Restore src/app? Discards 7 changed files in 3 repositories. y restore · n cancel
|
Restore src/app? Discards 7 changed files in 3 repositories. y restore · n cancel
|
||||||
@@ -114,6 +117,10 @@ lost, you are asked first:
|
|||||||
submodule with a staged pin, `x` first asks whether to restore to the staged
|
submodule with a staged pin, `x` first asks whether to restore to the staged
|
||||||
pin (`s`) or the committed one (`c`, which unstages it).
|
pin (`s`) or the committed one (`c`, which unstages it).
|
||||||
|
|
||||||
|
`X` restores to the committed pins without asking which one, and also
|
||||||
|
drops the staged pins of submodules below the selection, which `x` keeps
|
||||||
|
where it can. It asks if work or a staged pin would be lost.
|
||||||
|
|
||||||
### Switch a branch with `b`
|
### Switch a branch with `b`
|
||||||
|
|
||||||
Press `b` on a submodule (or the header for the superproject). A popup lists
|
Press `b` on a submodule (or the header for the superproject). A popup lists
|
||||||
@@ -131,11 +138,22 @@ the local and remote branches, newest first; type to filter.
|
|||||||
|
|
||||||
`↑` `↓` select, `enter` switches, `esc` cancels. The tip of the branch
|
`↑` `↓` select, `enter` switches, `esc` cancels. The tip of the branch
|
||||||
becomes the submodule's staged pin and it is restored to it, with HEAD on the
|
becomes the submodule's staged pin and it is restored to it, with HEAD on the
|
||||||
branch. Picking `origin/feat` creates a local `feat` that tracks it; an
|
branch. Picking `origin/feat` creates a local `feat` that tracks it. Like restore, it asks
|
||||||
existing local branch of that name is never moved. Like restore, it asks
|
|
||||||
first if work would be lost. The list is read from disk, so press `f` first
|
first if work would be lost. The list is read from disk, so press `f` first
|
||||||
to see new remote branches.
|
to see new remote branches.
|
||||||
|
|
||||||
|
An existing local branch that is only behind the remote branch is
|
||||||
|
fast-forwarded; one with commits of its own is never moved.
|
||||||
|
|
||||||
|
### Update a pin with `u`
|
||||||
|
|
||||||
|
On a submodule whose pin is behind its branch (`origin/main ↓2`), `u` moves
|
||||||
|
the pin to that branch's tip: the same as picking `origin/main` with `b`,
|
||||||
|
without the popup. On the header it fast-forwards the superproject's branch
|
||||||
|
to its upstream (`main ↓1`), like `git pull --ff-only`, and restores the
|
||||||
|
submodules. It takes exactly the commits shown and fetches nothing; press
|
||||||
|
`f` first to see newer ones.
|
||||||
|
|
||||||
### Fetch and reload
|
### Fetch and reload
|
||||||
|
|
||||||
`f` fetches all repositories again, `r` reloads everything after you edited
|
`f` fetches all repositories again, `r` reloads everything after you edited
|
||||||
@@ -150,13 +168,15 @@ files.
|
|||||||
| `H` `L` / `⇧←` `⇧→` | Collapse / expand all |
|
| `H` `L` / `⇧←` `⇧→` | Collapse / expand all |
|
||||||
| `space` | Toggle the selected submodule |
|
| `space` | Toggle the selected submodule |
|
||||||
| `home` `end` / `g` `G`, `pgup` `pgdn` | Jump, page |
|
| `home` `end` / `g` `G`, `pgup` `pgdn` | Jump, page |
|
||||||
| `/` | Filter |
|
|
||||||
| `!` | Problems only |
|
| `!` | Problems only |
|
||||||
|
| `/` | Filter |
|
||||||
| `c` | Copy path |
|
| `c` | Copy path |
|
||||||
|
| `u` | Update: move a pin that is behind to the tip of its branch (staged, like `b`); on the header, fast-forward the superproject |
|
||||||
| `b` | Switch branch: pick a local or remote branch; its tip is staged and the repository restored to it |
|
| `b` | Switch branch: pick a local or remote branch; its tip is staged and the repository restored to it |
|
||||||
| `x` | Restore: make the worktree exactly what git records (asks if work would be lost) |
|
| `x` | Restore to pin: make the worktree exactly what git records (asks if work would be lost) |
|
||||||
|
| `X` | Restore committed pins: like `x`, but dropping all staged pins below (asks first) |
|
||||||
| `f` / `r` | Fetch / reload |
|
| `f` / `r` | Fetch / reload |
|
||||||
| `?` | Full help |
|
| `?` | Full help: every key, then what the words and symbols mean (`esc` closes it) |
|
||||||
| `q` | Quit. If a newer release exists, the footer offers `q quit and update to vX` (highlighted) and plain quit moves to `Q` |
|
| `q` | Quit. If a newer release exists, the footer offers `q quit and update to vX` (highlighted) and plain quit moves to `Q` |
|
||||||
|
|
||||||
The mouse works too: click to select, click `▸` to toggle, wheel to move. In
|
The mouse works too: click to select, click `▸` to toggle, wheel to move. In
|
||||||
@@ -165,8 +185,8 @@ click outside to close it.
|
|||||||
|
|
||||||
## Status
|
## Status
|
||||||
|
|
||||||
A prototype. Browsing is complete. Two write actions exist: restore (`x`)
|
A prototype. Browsing is complete. Three write actions exist: restore (`x`),
|
||||||
and switch branch (`b`). More (stage pin) are being specified in
|
switch branch (`b`) and update pin (`u`), see
|
||||||
[docs/actions.md](docs/actions.md).
|
[docs/actions.md](docs/actions.md).
|
||||||
|
|
||||||
## Documentation
|
## Documentation
|
||||||
|
|||||||
+10
-4
@@ -14,7 +14,7 @@ bare commit hash.
|
|||||||
| [state-model.md](state-model.md) | How a submodule's state is determined, summarized into issues, and how pins are resolved to refs |
|
| [state-model.md](state-model.md) | How a submodule's state is determined, summarized into issues, and how pins are resolved to refs |
|
||||||
| [ux-guide.md](ux-guide.md) | Layout, attention hierarchy, colors, wording, navigation and key bindings |
|
| [ux-guide.md](ux-guide.md) | Layout, attention hierarchy, colors, wording, navigation and key bindings |
|
||||||
| [architecture.md](architecture.md) | Package layout, the provider boundary, async loading, testing |
|
| [architecture.md](architecture.md) | Package layout, the provider boundary, async loading, testing |
|
||||||
| [actions.md](actions.md) | the write actions restore and switch branch (done) and stage pin (draft) |
|
| [actions.md](actions.md) | the write actions restore, switch branch and update pin |
|
||||||
| [decisions.md](decisions.md) | Decision log with rationale (ADR style) |
|
| [decisions.md](decisions.md) | Decision log with rationale (ADR style) |
|
||||||
|
|
||||||
## Install
|
## Install
|
||||||
@@ -33,10 +33,16 @@ Installs `submarine` and the alias `sub` (linux/macOS, amd64/arm64) to
|
|||||||
through a git provider (`internal/submodule/git`): `submarine [dir]`. It
|
through a git provider (`internal/submodule/git`): `submarine [dir]`. It
|
||||||
fetches only when asked (`f`) and follows changes made in other shells. `submarine --mock` shows
|
fetches only when asked (`f`) and follows changes made in other shells. `submarine --mock` shows
|
||||||
the built-in mock data (`internal/submodule/mock`). `!` shows only
|
the built-in mock data (`internal/submodule/mock`). `!` shows only
|
||||||
submodules with problems. `x` restores the selection and `b` switches its
|
submodules with problems. `x` restores the selection (`X` also drops
|
||||||
branch ([actions.md](actions.md)); stage pin is drafted there.
|
staged pins), `b` switches its
|
||||||
|
branch and `u` updates a pin that is behind ([actions.md](actions.md)).
|
||||||
|
|
||||||
Next steps, roughly in order:
|
Next steps, roughly in order:
|
||||||
|
|
||||||
1. Stage pin, see [actions.md](actions.md). (Restore and switch branch are done.)
|
1. (done) Write actions: restore, switch branch, update pin, see
|
||||||
|
[actions.md](actions.md). Stage pin was dropped (D29).
|
||||||
2. (done) "Problems only" (`!`) view, reusing the tree's filter.
|
2. (done) "Problems only" (`!`) view, reusing the tree's filter.
|
||||||
|
3. Usability pass for users new to submodules, see
|
||||||
|
[notes/usability-plan.md](../notes/usability-plan.md). The low-effort
|
||||||
|
items are done (D31, D32); next are the issue hints and the palette
|
||||||
|
that fits the selection.
|
||||||
|
|||||||
+149
-66
@@ -1,8 +1,10 @@
|
|||||||
# Actions (draft)
|
# Actions (draft)
|
||||||
|
|
||||||
**Status:** [Restore](#restore) (key `x`, [D23](decisions.md)) and
|
**Status:** [Restore](#restore) (key `x`, [D23](decisions.md); hard restore
|
||||||
[Switch branch](#switch-branch) (key `b`, [D24](decisions.md)) are implemented. [Stage pin](#stage-pin) is still a draft; its open
|
`X`, [D30](decisions.md)),
|
||||||
questions are [at the end](#open-questions).
|
[Switch branch](#switch-branch) (key `b`, [D24](decisions.md)) and
|
||||||
|
[Update pin](#update-pin) (key `u`, [D29](decisions.md)) are implemented.
|
||||||
|
Stage pin was dropped ([D29](decisions.md)).
|
||||||
|
|
||||||
## Principle
|
## Principle
|
||||||
|
|
||||||
@@ -12,13 +14,13 @@ a difference in one of two directions:
|
|||||||
|
|
||||||
| Action | Direction | Key |
|
| Action | Direction | Key |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| [Restore](#restore) | The worktree follows the recorded state | `x` |
|
| [Restore](#restore) | The worktree follows the recorded state | `x`, `X` (hard) |
|
||||||
| [Stage pin](#stage-pin) | The recorded state follows the worktree | `s` |
|
|
||||||
| [Switch branch](#switch-branch) | Both: the branch tip is recorded, then restored to | `b` |
|
| [Switch branch](#switch-branch) | Both: the branch tip is recorded, then restored to | `b` |
|
||||||
|
| [Update pin](#update-pin) | Both: a switch to the tip of the pin's ref | `u` |
|
||||||
|
|
||||||
There is nothing else for now. Committing, pushing and bumping a pin to the
|
There is nothing else for now. Committing and pushing are done outside
|
||||||
tip are done outside submarine. A bump is "check out the tip in the
|
submarine. Recording a commit made in a submodule is a switch to its branch
|
||||||
submodule, then `s`" (or switch to the branch with `b`).
|
(`b`); a commit on no branch must get one first.
|
||||||
|
|
||||||
## Restore
|
## Restore
|
||||||
|
|
||||||
@@ -65,6 +67,33 @@ Two exceptions, where a staged pin is not kept:
|
|||||||
`.gitmodules` entry is reset with the other files. Its directory then
|
`.gitmodules` entry is reset with the other files. Its directory then
|
||||||
counts as an untracked file.
|
counts as an untracked file.
|
||||||
|
|
||||||
|
### Hard restore
|
||||||
|
|
||||||
|
`X` restores to the **committed** pins, at every depth: what the commits
|
||||||
|
record, without any staged pin.
|
||||||
|
|
||||||
|
- The selected submodule goes to the committed pin, without the `s` / `c`
|
||||||
|
question; a staged pin of it is unstaged (as with `c`). A submodule that
|
||||||
|
was added but not committed yet has no committed pin and goes to the one
|
||||||
|
in the index.
|
||||||
|
- Every restored repository's index is reset to its `HEAD`, the superproject
|
||||||
|
included: its staged pins are dropped, and the submodules below follow the
|
||||||
|
committed tree.
|
||||||
|
|
||||||
|
So `x` and `X` differ in the staged pins of repositories that the restore
|
||||||
|
does not move: `x` (`s` or `c`) keeps them, `X` drops them. This matters
|
||||||
|
for the superproject, for a selected submodule without a staged pin of its
|
||||||
|
own, and for nested repositories that are on their pin. A repository that
|
||||||
|
moves loses its staged pins either way (see above); then `x` → `c` and `X`
|
||||||
|
end the same, but `c` asks first.
|
||||||
|
|
||||||
|
The dropped staged pins are counted in the loss (`2 staged pins`), the
|
||||||
|
selected submodule's own staged pin included, and like any loss they make
|
||||||
|
restore ask (D31): `Restore libs/staged to the committed pins? Discards 1
|
||||||
|
staged pin.` The
|
||||||
|
commits of a dropped pin stay in the submodule; if HEAD leaves them and no
|
||||||
|
ref reaches them, they count as `commits on no branch`, as always.
|
||||||
|
|
||||||
### Postconditions
|
### Postconditions
|
||||||
|
|
||||||
After a successful restore of repository R with target T:
|
After a successful restore of repository R with target T:
|
||||||
@@ -86,7 +115,8 @@ After a successful restore of repository R with target T:
|
|||||||
any depth.
|
any depth.
|
||||||
|
|
||||||
So the subtree shows `in sync` everywhere, except for states restore does
|
So the subtree shows `in sync` everywhere, except for states restore does
|
||||||
not change: `unpushed`, behind/ahead of the ref, and `detached HEAD on pin`.
|
not change: `unpushed`, behind/ahead of the ref, and `detached HEAD on pin`
|
||||||
|
where no branch is at the pin (see [Branches](#branches)).
|
||||||
|
|
||||||
The recursion follows **T**, not the tree loaded in the UI. A submodule that
|
The recursion follows **T**, not the tree loaded in the UI. A submodule that
|
||||||
is only in T (for example, after switching branches) is initialized. A
|
is only in T (for example, after switching branches) is initialized. A
|
||||||
@@ -100,8 +130,25 @@ submodule that is only on disk is deleted as an untracked directory.
|
|||||||
the branch stays checked out.
|
the branch stays checked out.
|
||||||
- **Submodule off the pin:** HEAD is detached at the target. The branch that
|
- **Submodule off the pin:** HEAD is detached at the target. The branch that
|
||||||
was checked out is **not moved**, so its commits stay on it.
|
was checked out is **not moved**, so its commits stay on it.
|
||||||
|
- **Submodule detached at the target** (after the step above, or because it
|
||||||
|
was already so, also when it was in sync): HEAD is put on a branch whose
|
||||||
|
tip is the target, the first that fits of ([D28](decisions.md)):
|
||||||
|
1. a local branch tracking the ref the UI shows (or named like it), e.g.
|
||||||
|
local `main` tracking `origin/main`, also when the pin is behind or
|
||||||
|
ahead of the ref;
|
||||||
|
2. the ref itself, if its tip is the target and no local branch has its
|
||||||
|
name: the local branch is created, tracking it;
|
||||||
|
3. any other local branch, the first alphabetically;
|
||||||
|
4. any other remote branch whose name no local branch has, created as a
|
||||||
|
tracking branch; the ref's remote first, then alphabetically.
|
||||||
|
|
||||||
Restore never moves, creates or deletes a branch.
|
If git refuses one (checked out in another worktree, a name conflict),
|
||||||
|
the next is tried. If none fits, HEAD stays detached.
|
||||||
|
|
||||||
|
Restore never moves or deletes a branch. A local branch that is behind or
|
||||||
|
ahead of its remote branch is no candidate and is not fast-forwarded. The
|
||||||
|
only branches restore creates are tracking branches for remote branches at
|
||||||
|
the target, in steps 2 and 4.
|
||||||
|
|
||||||
### What can be lost
|
### What can be lost
|
||||||
|
|
||||||
@@ -113,7 +160,7 @@ Restore discards work. The things it can lose:
|
|||||||
| Untracked files | `changed` (untracked) |
|
| Untracked files | `changed` (untracked) |
|
||||||
| Conflicts and in-progress operations | `conflict`; merge, rebase, cherry-pick, revert state |
|
| Conflicts and in-progress operations | `conflict`; merge, rebase, cherry-pick, revert state |
|
||||||
| Commits | A detached HEAD off the pin whose commits are on no branch, tag or remote ref (only the reflog still has them) |
|
| Commits | A detached HEAD off the pin whose commits are on no branch, tag or remote ref (only the reflog still has them) |
|
||||||
| Staged pins | Staged pins in a repository that restore moves to another commit (see [Target](#target)) |
|
| Staged pins | Staged pins in a repository that restore moves to another commit (see [Target](#target)); with `X` all staged pins |
|
||||||
| Repositories | An untracked directory that is a repository with its **own** `.git` directory (a clone that was never added as a submodule). Its commits may exist nowhere else. A removed submodule whose git directory is absorbed into `.git/modules` loses only its worktree. Its objects stay |
|
| Repositories | An untracked directory that is a repository with its **own** `.git` directory (a clone that was never added as a submodule). Its commits may exist nowhere else. A removed submodule whose git directory is absorbed into `.git/modules` loses only its worktree. Its objects stay |
|
||||||
|
|
||||||
Nothing else is lost. Branches, stashes, remote refs, ignored files and
|
Nothing else is lost. Branches, stashes, remote refs, ignored files and
|
||||||
@@ -187,8 +234,18 @@ submodule update --init --checkout --force -- <not yet initialized children>
|
|||||||
`--checkout` overrides `submodule.<name>.update`, and the explicit paths
|
`--checkout` overrides `submodule.<name>.update`, and the explicit paths
|
||||||
override `submodule.<name>.active`, so no child module is skipped.
|
override `submodule.<name>.active`, so no child module is skipped.
|
||||||
|
|
||||||
With `c` the parent's index gets the committed pin first (`update-index
|
For a submodule, after `submodule sync`, `attachBranch` puts a detached HEAD
|
||||||
--cacheinfo` in the parent).
|
on a branch: `symbolic-ref -q HEAD` (on a branch: done), `for-each-ref
|
||||||
|
--points-at=T refs/heads refs/remotes` (no branch at T: done), then the ref
|
||||||
|
is resolved like `Status` does, and the first candidate (see
|
||||||
|
[Branches](#branches)) that `switch` / `switch -c <local> --track <remote>`
|
||||||
|
accepts wins. The branch checked out before the restore is passed to the
|
||||||
|
ref resolution. A child's `.gitmodules` branch is read once per parent.
|
||||||
|
|
||||||
|
With `c` and `X` the parent's index gets the committed pin first
|
||||||
|
(`update-index --cacheinfo` in the parent). With `X` (`RestoreHard`) the
|
||||||
|
staged pins are not put back, and the loss check takes the children's
|
||||||
|
targets from `HEAD`'s tree instead of the index.
|
||||||
|
|
||||||
`RestoreLoss` (`lossOf`) follows the repositories on disk: per repository
|
`RestoreLoss` (`lossOf`) follows the repositories on disk: per repository
|
||||||
`status` (without submodules), the in-progress state files, and
|
`status` (without submodules), the in-progress state files, and
|
||||||
@@ -232,9 +289,14 @@ For a **submodule**, with the tip `T` the list showed:
|
|||||||
2. The submodule, and everything below it, is [restored](#restore) to `T`.
|
2. The submodule, and everything below it, is [restored](#restore) to `T`.
|
||||||
3. HEAD is put on the branch (the branch name is the local one):
|
3. HEAD is put on the branch (the branch name is the local one):
|
||||||
- a local branch: checked out;
|
- a local branch: checked out;
|
||||||
- a remote branch `origin/feat`: the local branch `feat` is created,
|
- a remote branch `origin/feat`: the local branch is the one checked out
|
||||||
tracking it; if `feat` exists at `T` it is checked out; if it exists
|
before if it tracks `origin/feat` (e.g. `dev`), else `feat`. It is
|
||||||
**elsewhere** it is not moved and HEAD stays detached at `T`.
|
created, tracking it, if it does not exist; if it exists at `T` it is
|
||||||
|
checked out; if it is only
|
||||||
|
**behind** `T` (an ancestor of it) it is fast-forwarded to `T` and
|
||||||
|
checked out ([D29](decisions.md)); if it has commits of its own, or is
|
||||||
|
checked out in another worktree, it is **not moved** and HEAD stays
|
||||||
|
detached at `T`.
|
||||||
|
|
||||||
For the **superproject** step 1 is left out: the branch is checked out via the
|
For the **superproject** step 1 is left out: the branch is checked out via the
|
||||||
restore, so its submodules follow the pins of the new commit (staged pins of
|
restore, so its submodules follow the pins of the new commit (staged pins of
|
||||||
@@ -253,7 +315,7 @@ only if something would be lost. A submodule that is not initialized can't be
|
|||||||
switched (`src/app is not initialized; restore it first`). Network: only what
|
switched (`src/app is not initialized; restore it first`). Network: only what
|
||||||
is missing below, like restore. Lines above the footer: `checking …`,
|
is missing below, like restore. Lines above the footer: `checking …`,
|
||||||
`switching src/app to origin/feat…`; flash `switched src/app to feat` (or
|
`switching src/app to origin/feat…`; flash `switched src/app to feat` (or
|
||||||
`… to origin/feat (detached: local feat is elsewhere)`); failure
|
`… to origin/feat (detached: local feat can't be fast-forwarded)`); failure
|
||||||
`switch of src/app to origin/feat failed: …`, until the next key. One
|
`switch of src/app to origin/feat failed: …`, until the next key. One
|
||||||
restore or switch runs at a time.
|
restore or switch runs at a time.
|
||||||
|
|
||||||
@@ -261,70 +323,81 @@ restore or switch runs at a time.
|
|||||||
|
|
||||||
`git.Provider.Switch` (`internal/submodule/git/switch.go`): verify the ref is
|
`git.Provider.Switch` (`internal/submodule/git/switch.go`): verify the ref is
|
||||||
still `T`; `update-index --cacheinfo` in the parent; `restoreRepo` (see
|
still `T`; `update-index --cacheinfo` in the parent; `restoreRepo` (see
|
||||||
restore); then `switch` / `switch -c <local> --track <remote-branch>`.
|
restore); then `switch` / `switch -c <local> --track <remote-branch>`; a local branch
|
||||||
|
behind `T` is first moved with `merge-base --is-ancestor <local> T`, a look
|
||||||
|
at `worktree list --porcelain` and `update-ref refs/heads/<local> T <old>`
|
||||||
|
(which fails if the branch moved meanwhile). The restore leaves HEAD of the
|
||||||
|
switched repository alone (Switch puts it on the chosen branch); the
|
||||||
|
submodules below it are attached as in a restore.
|
||||||
`SwitchLoss` is `lossOf` with `T` as the target. `Branches` is one
|
`SwitchLoss` is `lossOf` with `T` as the target. `Branches` is one
|
||||||
`for-each-ref`, without the `origin/HEAD` symref.
|
`for-each-ref`, without the `origin/HEAD` symref.
|
||||||
|
|
||||||
## Stage pin
|
## Update pin
|
||||||
|
|
||||||
Records the commit checked out in a submodule as its new pin in the
|
Moves the pin of a submodule to the tip of its ref, the branch the row shows
|
||||||
parent's index. It is the same as `git add <path>` in the parent, but only
|
(`origin/main ↓2`). It is a [switch](#switch-branch) to that branch, with the
|
||||||
when it is safe.
|
tip the row shows: nothing is fetched, so `u` gives exactly the commits the
|
||||||
|
row counts. `f` is what brings newer ones.
|
||||||
|
|
||||||
|
On the **superproject** the pin is HEAD and the ref is the upstream of its
|
||||||
|
branch (the header's `main ↓1`): `u` fast-forwards the branch to it, like
|
||||||
|
`git pull --ff-only`, and restores the submodules to the pins of the new
|
||||||
|
commit.
|
||||||
|
|
||||||
### Available when
|
### Available when
|
||||||
|
|
||||||
All of:
|
All of:
|
||||||
|
|
||||||
- the selected node is a submodule, not the superproject;
|
- it is initialized and its state is loaded;
|
||||||
- it is `off pin` (`Checkout == CheckoutDiffers`): HEAD differs from the
|
- its ref is a remote branch (not a tag, a SHA or a local branch) and has a
|
||||||
pin in the parent's index;
|
tip; for the superproject: its branch has an upstream;
|
||||||
- its worktree is **clean** (`Worktree == WorktreeClean`). Uncommitted
|
- the pin is **behind** the ref and not ahead (`Behind > 0`, `Ahead == 0`).
|
||||||
changes, untracked files and conflicts count as dirty, the same rule as
|
A diverged pin (`↑n↓m`) is left to `b`: moving it would drop the pin's
|
||||||
the `changed` issue. A moved nested pin counts as a change too, so nested
|
own commits from the record.
|
||||||
submodules are staged bottom-up: first inside the submodule, then the
|
|
||||||
submodule itself (after a commit in it, outside submarine).
|
|
||||||
|
|
||||||
Changes *inside* nested submodules don't count. Like the `changed` issue,
|
|
||||||
staging only looks at the submodule's own worktree.
|
|
||||||
|
|
||||||
### Effect
|
### Effect
|
||||||
|
|
||||||
- The parent's index records the submodule's HEAD as the gitlink of its
|
The same as [switching](#switch-branch) to the ref: the tip is staged
|
||||||
path. Nothing else changes: no worktree, no other index entry, no commit.
|
as the pin, the submodule and everything below it are restored to it, and
|
||||||
- The submodule then shows `staged` (or `●` next to an issue), and the
|
HEAD is put on the local branch. A local branch that is only behind is
|
||||||
`off pin` issue is gone.
|
fast-forwarded, so the common case (local `main` at the old pin) ends on
|
||||||
- If the checkout equals the pin in the parent's `HEAD`, staging removes
|
`main`. A checked-out branch that tracks the ref under another name (`dev`
|
||||||
the staged pin instead (the index equals `HEAD` again).
|
tracking `origin/main`) is the one used, so the update stays on it. For the
|
||||||
- If the checked-out commit is not pushed, it is staged anyway. The row
|
superproject staged pins are lost, as in a switch (and counted).
|
||||||
then shows `unpushed` (Danger), which is the warning.
|
|
||||||
- There is no confirmation: nothing is lost.
|
If the ref no longer points at the tip the row showed (a fetch meanwhile),
|
||||||
- Undo: restore the submodule and choose `c` (committed pin), see
|
nothing happens: `can't update src/app: origin/main moved; check the new
|
||||||
[Target](#target).
|
state`. The watcher has updated the row by then; `u` again takes the new
|
||||||
|
tip.
|
||||||
|
|
||||||
|
### Confirmation, lines, errors
|
||||||
|
|
||||||
|
As for switch: the question appears only if something would be lost
|
||||||
|
(`Update src/app to origin/main? Discards … y update · n cancel`). Lines
|
||||||
|
above the footer: `checking src/app…`, `updating src/app to origin/main…`;
|
||||||
|
flash `updated src/app to main`; failure `update of src/app to origin/main
|
||||||
|
failed: …`.
|
||||||
|
|
||||||
### Implementation note
|
### Implementation note
|
||||||
|
|
||||||
```sh
|
The UI only: `startPinUpdate` (`internal/tui/pinupdate.go`) reads the
|
||||||
# in the submodule
|
branches (`Switcher.Branches`), takes the one named like `Pin.Ref`, checks
|
||||||
git rev-parse HEAD # must still be the SHA shown in the UI, else abort
|
that its tip still starts with the abbreviated `Status.Tip.SHA`, and then
|
||||||
# in the parent
|
runs the switch flow with it (`SwitchLoss`, `Switch`).
|
||||||
git update-index --cacheinfo 160000,<sha>,<path>
|
|
||||||
```
|
|
||||||
|
|
||||||
`update-index` stages exactly the SHA that was checked, independent of the
|
|
||||||
worktree. Flash: `staged pin of src/app` on success; git's message on
|
|
||||||
failure.
|
|
||||||
|
|
||||||
## Keys and palette
|
## Keys and palette
|
||||||
|
|
||||||
Both are submodule keys (`keyMap.Submodule`), shown in the detail pane's
|
All three are submodule keys (`keyMap.Submodule`), shown in the detail
|
||||||
palette, or in the footer when the pane can't show it. Restore is always
|
pane's palette, or in the footer when the pane can't show it. They are
|
||||||
offered: it is idempotent, and the palette is static.
|
always offered, the palette is static: restore is idempotent, and `u` says
|
||||||
|
why it can't update. Only `u`'s label follows the selection: on the header
|
||||||
|
it reads `pull (fast-forward)`.
|
||||||
|
|
||||||
| Key | Action | Pressed when not possible (flash) |
|
| Key | Action | Pressed when not possible (flash) |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `x` restore | Restore the selection | `this data source can't restore` (mock); `wait for the restore of … to finish` |
|
| `x` restore to pin, `X` restore committed pins | Restore the selection; `X` see [Hard restore](#hard-restore) | `this data source can't restore` (mock); `wait for the restore of … to finish` |
|
||||||
| `b` switch branch | [Switch branch](#switch-branch) | `this data source can't switch branches`; `wait for the restore of … to finish`; `src/app is not initialized; restore it first` |
|
| `b` switch branch | [Switch branch](#switch-branch) | `this data source can't switch branches`; `wait for the restore of … to finish`; `src/app is not initialized; restore it first` |
|
||||||
| `s` stage pin | [Stage pin](#stage-pin), not implemented | `src/app is on its pin` / `src/app has uncommitted changes; commit or restore them first` / `the superproject has no pin` |
|
| `u` update pin (`pull (fast-forward)` on the header) | [Update pin](#update-pin) | `this data source can't update pins`; `wait for the restore of … to finish`; `the superproject has no upstream branch`; `src/app is still loading`; `src/app is not initialized; restore it first`; `the pin of src/app is not on a branch`; `the pin of src/app has diverged from origin/main; b switches the branch`; `the pin of src/app is ahead of origin/main`; `src/app is up to date with origin/main` (the superproject without `the pin of`); after the check `can't update src/app: main is a local branch; b switches to it` |
|
||||||
|
|
||||||
`R` would sit next to `r` (reload), a dangerous mix-up for a destructive
|
`R` would sit next to `r` (reload), a dangerous mix-up for a destructive
|
||||||
action, hence `x`.
|
action, hence `x`.
|
||||||
@@ -337,18 +410,30 @@ action, hence `x`.
|
|||||||
not moved, ignored files survive, a merge and a rebase in progress end, a
|
not moved, ignored files survive, a merge and a rebase in progress end, a
|
||||||
standalone clone and a removed submodule's directory are deleted, a missing
|
standalone clone and a removed submodule's directory are deleted, a missing
|
||||||
submodule is cloned again, `update = none` and inactive submodules are
|
submodule is cloned again, `update = none` and inactive submodules are
|
||||||
restored, an unavailable commit is reported. The loss is empty for off pin,
|
restored, an unavailable commit is reported. A detached submodule ends on
|
||||||
|
the local branch tracking the ref, else on the ref created as a tracking
|
||||||
|
branch (before other branches at the pin), else on the first local
|
||||||
|
branch; a local branch elsewhere is not moved; a checked-out branch stays;
|
||||||
|
a branch of another worktree is skipped; nested submodules are attached
|
||||||
|
too. The loss is empty for off pin,
|
||||||
missing and unpushed submodules, counts files, nested changes, merges,
|
missing and unpushed submodules, counts files, nested changes, merges,
|
||||||
detached commits (not those on a branch) and standalone repositories.
|
detached commits (not those on a branch) and standalone repositories.
|
||||||
- UI (`internal/tui/restore_test.go`): restores at once without a loss, asks
|
- UI (`internal/tui/restore_test.go`): restores at once without a loss, asks
|
||||||
with one (`y` restores, anything else cancels), the mock can't restore.
|
with one (`y` restores, anything else cancels), the mock can't restore.
|
||||||
- Switch: provider (`switch_test.go`): list without `origin/HEAD`, local
|
- Switch: provider (`switch_test.go`): list without `origin/HEAD`, local
|
||||||
first; a remote pick stages the tip and creates a tracking branch; an
|
first; a remote pick stages the tip and creates a tracking branch; a
|
||||||
existing local branch elsewhere is not moved; a moved ref aborts; the
|
local branch that is only behind is fast-forwarded; one with commits of
|
||||||
|
its own, or checked out in another worktree, is not moved; a moved ref aborts; the
|
||||||
superproject switch ends exact; the loss is the restore's. Picker
|
superproject switch ends exact; the loss is the restore's. Picker
|
||||||
(`branches_test.go`): filter, selection, scrolling. UI (`switch_test.go`):
|
(`branches_test.go`): filter, selection, scrolling. UI (`switch_test.go`):
|
||||||
opens, filters, enter switches, asks with a loss, esc closes, the mock
|
opens, filters, enter switches, asks with a loss, esc closes, the mock
|
||||||
can't.
|
can't.
|
||||||
|
- Update pin: UI (`pinupdate_test.go`): switches to the tip of the ref, asks
|
||||||
|
with a loss, aborts if the ref moved, refuses (with its flash) a pin that
|
||||||
|
is up to date, ahead, diverged, on a tag, on a local branch or not
|
||||||
|
initialized; updates the superproject, not one without an upstream.
|
||||||
|
Provider: the superproject's branch is fast-forwarded; a checked-out
|
||||||
|
branch that tracks the remote branch under another name is kept.
|
||||||
- By hand in tmux on a fixture, see
|
- By hand in tmux on a fixture, see
|
||||||
[architecture.md](architecture.md#testing).
|
[architecture.md](architecture.md#testing).
|
||||||
|
|
||||||
@@ -359,7 +444,5 @@ submodule is restored to the index gitlink, so a staged pin is its target and
|
|||||||
survives; restore may clone and fetch what is missing; the key is `x`; it
|
survives; restore may clone and fetch what is missing; the key is `x`; it
|
||||||
asks only if something would be lost.
|
asks only if something would be lost.
|
||||||
|
|
||||||
Open (stage pin):
|
Open (update pin): updating several submodules at once (on the superproject
|
||||||
|
or a collapsed parent), deliberately left out for now.
|
||||||
1. **Key.** `s`?
|
|
||||||
2. **Palette.** Show it always (like restore), or only while it applies?
|
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ internal/submodule/git/ provider using the git CLI (reads, fetch, watch)
|
|||||||
fetch.go Fetch (network), Fetchable
|
fetch.go Fetch (network), Fetchable
|
||||||
watch.go Watch: polls git metadata for changes
|
watch.go Watch: polls git metadata for changes
|
||||||
restore.go Restore, RestoreLoss (network: only what is missing)
|
restore.go Restore, RestoreLoss (network: only what is missing)
|
||||||
switch.go Branches, SwitchLoss, Switch (stage the tip, restore, attach the branch)
|
switch.go Branches, SwitchLoss, Switch (stage the tip, restore, attach or fast-forward the branch)
|
||||||
gitdir.go resolves .git dirs and gitdir: files
|
gitdir.go resolves .git dirs and gitdir: files
|
||||||
internal/submodule/mock/ fixture provider with artificial latency
|
internal/submodule/mock/ fixture provider with artificial latency
|
||||||
internal/update/ self-update from the package registry; NO UI imports
|
internal/update/ self-update from the package registry; NO UI imports
|
||||||
@@ -28,6 +28,7 @@ internal/tui/ Bubble Tea application
|
|||||||
app.go root model: layout, message routing, loading
|
app.go root model: layout, message routing, loading
|
||||||
keys.go key bindings (also feed the help view)
|
keys.go key bindings (also feed the help view)
|
||||||
restore.go, switch.go restore / switch flows; a switch reuses restoreState
|
restore.go, switch.go restore / switch flows; a switch reuses restoreState
|
||||||
|
pinupdate.go update pin (u): a switch to the pin's ref, at the tip the row shows
|
||||||
overlay.go draws the picker over the finished view at the position the picker reports
|
overlay.go draws the picker over the finished view at the position the picker reports
|
||||||
msgs.go tree/status/fetch/change messages
|
msgs.go tree/status/fetch/change messages
|
||||||
refresh.go reloader: one load per key, coalesced reruns
|
refresh.go reloader: one load per key, coalesced reruns
|
||||||
@@ -36,7 +37,7 @@ internal/tui/ Bubble Tea application
|
|||||||
detail/ detail pane; changes.go: change preview
|
detail/ detail pane; changes.go: change preview
|
||||||
header/ header line
|
header/ header line
|
||||||
branches/ branch picker popup (search line + list)
|
branches/ branch picker popup (search line + list)
|
||||||
footer/ key bar (navigation left, global right) and flash messages
|
footer/ key bar (navigation and tools left, global right), full help pages (keys, legend) and flash messages
|
||||||
```
|
```
|
||||||
|
|
||||||
### Layering rules
|
### Layering rules
|
||||||
|
|||||||
+137
-2
@@ -327,7 +327,8 @@ the same for every submodule of the target tree, initializing missing ones.
|
|||||||
- *Ignored files stay* (`clean` without `-x`): `.env`, `node_modules` and
|
- *Ignored files stay* (`clean` without `-x`): `.env`, `node_modules` and
|
||||||
IDE settings are not "additional files" worth deleting in every submodule.
|
IDE settings are not "additional files" worth deleting in every submodule.
|
||||||
- *Branches never move.* A checkout that is not on the target is detached
|
- *Branches never move.* A checkout that is not on the target is detached
|
||||||
first, so commits stay on their branch.
|
first, so commits stay on their branch. (A submodule is then put on a
|
||||||
|
branch at the target if there is one, see D28.)
|
||||||
- *It asks only if something is lost* (changed or untracked files,
|
- *It asks only if something is lost* (changed or untracked files,
|
||||||
operations in progress, commits on no ref, standalone repositories).
|
operations in progress, commits on no ref, standalone repositories).
|
||||||
Never asking would delete repositories that exist nowhere else; always
|
Never asking would delete repositories that exist nowhere else; always
|
||||||
@@ -361,7 +362,7 @@ Enter on a branch of a submodule stages its tip as the pin in the parent's
|
|||||||
index and restores the submodule to it (D23, recursion included); then HEAD
|
index and restores the submodule to it (D23, recursion included); then HEAD
|
||||||
is put on the branch. On the superproject it checks the branch out and
|
is put on the branch. On the superproject it checks the branch out and
|
||||||
restores below, without staging.
|
restores below, without staging.
|
||||||
- *Key `b`*, not `s`, which stays reserved for stage pin.
|
- *Key `b`*, not `s`, which stays reserved for stage pin (dropped, D29).
|
||||||
- *Remote branch.* A local branch of that name is created, tracking it. If
|
- *Remote branch.* A local branch of that name is created, tracking it. If
|
||||||
one exists at another commit it is **not moved**: HEAD stays detached at
|
one exists at another commit it is **not moved**: HEAD stays detached at
|
||||||
the tip and the flash says so (restore never moves branches, D23).
|
the tip and the flash says so (restore never moves branches, D23).
|
||||||
@@ -499,3 +500,137 @@ timeout, failures are silent), which supersedes the once-a-day rule of D21.
|
|||||||
N1 still holds: the TUI never waits for it. `q` changes meaning while an
|
N1 still holds: the TUI never waits for it. `q` changes meaning while an
|
||||||
update is available, so muscle memory that exits with `q` now updates; the
|
update is available, so muscle memory that exits with `q` now updates; the
|
||||||
bar says so. Typing `q` in the filter or a popup is unchanged.
|
bar says so. Typing `q` in the filter or a popup is unchanged.
|
||||||
|
|
||||||
|
## D28 — Restore puts a submodule on a branch at its pin
|
||||||
|
|
||||||
|
**Context.** After a restore (for example after the superproject's branch
|
||||||
|
changed) every submodule was a detached HEAD, so IDEs showed only hashes.
|
||||||
|
**Decision.** Amends D23. After restoring a submodule, at any depth, a
|
||||||
|
detached HEAD is put on a branch whose tip is the pin, in this order: (1) a
|
||||||
|
local branch tracking the shown ref (or named like it), (2) the ref itself
|
||||||
|
as a new local tracking branch, if no local branch has its name, (3) any
|
||||||
|
other local branch, alphabetically, (4) any other remote branch whose name
|
||||||
|
no local branch has, as a new tracking branch, the ref's remote first. A
|
||||||
|
HEAD already on a branch stays there. If git refuses a branch (checked out
|
||||||
|
in another worktree, a name conflict), the next one is tried; if none fits,
|
||||||
|
HEAD stays detached and the restore still succeeds. Submodules that were in
|
||||||
|
sync are included. The superproject is not affected.
|
||||||
|
**Rationale.** Attaching a branch whose tip is the pin changes no commit and
|
||||||
|
no file, so nothing can be lost and the loss question stays the same. The
|
||||||
|
ref comes first because it is what the UI shows; a local branch beats a new
|
||||||
|
one so restore adds as few branches as possible.
|
||||||
|
**Alternatives.** Fast-forwarding a local branch that is behind its remote
|
||||||
|
branch at the pin: rejected, restore still never moves a branch. Staying
|
||||||
|
detached when several branches fit: rejected, the order above always gives
|
||||||
|
one answer, the same on every run.
|
||||||
|
**Consequences.** Restore now may create local branches (only for remote
|
||||||
|
branches at the pin whose name has none). Per detached submodule it costs a
|
||||||
|
`symbolic-ref` and one `for-each-ref --points-at`; only if a branch is at
|
||||||
|
the pin, the ref is resolved (as for `Status`) and one `switch` runs. No
|
||||||
|
network. `detached HEAD on pin` is now left only where no branch is at the
|
||||||
|
pin. Switch keeps its own rule for the repository it switches; the
|
||||||
|
submodules below it follow this one.
|
||||||
|
|
||||||
|
## D29 — Update pin instead of stage pin; switch fast-forwards a branch behind
|
||||||
|
|
||||||
|
**Context.** Stage pin (record the checkout as the pin) was drafted, but a
|
||||||
|
commit made in a submodule is on a branch, and switching to that branch
|
||||||
|
(`b`) already stages it. The common job is a different one: a pin that is
|
||||||
|
behind its branch (`origin/main ↓2`) should move to the tip. With `b` that
|
||||||
|
is a search in the popup, and it often ended detached, because the local
|
||||||
|
`main` sat at the old pin and was never moved (D24).
|
||||||
|
**Decision.** `u` updates the pin of a submodule to the tip of its ref, only
|
||||||
|
when the pin is behind it and not ahead. It is a switch to that branch (D24)
|
||||||
|
with the tip the row shows: nothing is fetched, and if the ref moved
|
||||||
|
meanwhile nothing happens. Switch (and so `u`) now fast-forwards a local
|
||||||
|
branch that is an ancestor of the tip, unless a worktree has it checked out;
|
||||||
|
`update-ref` with the old tip makes the move fail if the branch moved. Stage
|
||||||
|
pin is dropped.
|
||||||
|
- *No fetch.* The user sees `↓2` and gets those two commits; a fetch first
|
||||||
|
would update to something the screen never showed. `f` is separate.
|
||||||
|
- *Only behind.* A diverged pin has commits the ref lacks; moving it would
|
||||||
|
drop them from the record, so that is a deliberate `b`.
|
||||||
|
- *The superproject too.* Its pin is HEAD and its ref the upstream, so `u`
|
||||||
|
there is `git pull --ff-only` plus a restore of the submodules. Switch
|
||||||
|
keeps a checked-out branch that tracks the chosen remote branch under
|
||||||
|
another name (`dev` tracking `origin/main`), so an update never changes
|
||||||
|
the branch.
|
||||||
|
- *One at a time.* Updating several at once is left for later.
|
||||||
|
- *Fast-forward only.* A local branch with commits of its own is never
|
||||||
|
moved, as before. Restore (D23, D28) still never moves a branch: it goes
|
||||||
|
back to the recorded pin, where moving branches has no place.
|
||||||
|
**Alternatives.** Keeping stage pin: it covers only a commit on no branch,
|
||||||
|
which then is `unpushed` anyway. `u` fetching first (a real pull): rejected,
|
||||||
|
see above. Resetting a local branch with commits of its own: loses them from
|
||||||
|
the branch.
|
||||||
|
**Consequences.** Amends D24 ("not moved") and D28's note that switch keeps
|
||||||
|
its own rule. No new provider interface: `u` is `Branches`, `SwitchLoss` and
|
||||||
|
`Switch`. Since `Status.Tip.SHA` is abbreviated, the UI compares it as a
|
||||||
|
prefix of the branch's full SHA.
|
||||||
|
|
||||||
|
## D30 — Hard restore drops staged pins at every depth
|
||||||
|
|
||||||
|
**Context.** `x` keeps staged pins at every depth (D23), except in a
|
||||||
|
repository the restore moves. Its `c` answer unstages only the selected
|
||||||
|
submodule's pin. Staged pins of the superproject, or of nested repositories
|
||||||
|
that stay on their pin, could only be dropped one by one.
|
||||||
|
**Decision.** Amends D23. `X` is a hard restore: the selected submodule goes
|
||||||
|
to its committed pin without the `s` / `c` question, and every restored
|
||||||
|
repository's index is reset to `HEAD` (the superproject's too), so all
|
||||||
|
staged pins below are dropped and the submodules follow the committed
|
||||||
|
trees. Dropped staged pins are counted in the loss and listed if the
|
||||||
|
question appears, but alone they don't make it appear. `x` and its question
|
||||||
|
stay as they are.
|
||||||
|
**Rationale.** A separate key keeps the common `x` free of a further
|
||||||
|
question and makes the stronger action deliberate. A staged pin is only a
|
||||||
|
recorded SHA; its commit stays in the submodule, and if HEAD leaves a
|
||||||
|
commit no ref reaches, `commits on no branch` still asks.
|
||||||
|
**Alternatives.** A third answer in the `s` / `c` question: only reachable
|
||||||
|
for a submodule with a staged pin itself, not for the superproject or a
|
||||||
|
clean parent. Dropping the question from `x`: rejected, the choice between
|
||||||
|
staged and committed pin is still useful there.
|
||||||
|
**Consequences.** `RestoreTarget` gets `RestoreHard`, which also matters for
|
||||||
|
the superproject. `restoreRepo` and `lossOf` take whether to keep staged
|
||||||
|
pins; switch always keeps them. Without a committed pin (an added
|
||||||
|
submodule), `X` uses the index pin.
|
||||||
|
|
||||||
|
## D31 — Hard restore asks before dropping staged pins
|
||||||
|
|
||||||
|
**Context.** D30 let `X` drop staged pins without a question: pressing `X`
|
||||||
|
was the choice. In a usability walkthrough (see
|
||||||
|
[notes/usability-plan.md](../notes/usability-plan.md)) `X` on `libs/staged`
|
||||||
|
unstaged its pin silently. `X` is one Shift away from `x`, and users new to
|
||||||
|
submodules don't know where to find the dropped commit again.
|
||||||
|
**Decision.** Amends D30. Dropped staged pins are a loss like any other and
|
||||||
|
make `X` ask: `Restore libs/staged to the committed pins? Discards 1 staged
|
||||||
|
pin.` `RestoreLoss` with `RestoreHard` now also counts the selected
|
||||||
|
submodule's own staged pin (in its parent's index), which `lossOf` doesn't
|
||||||
|
see. The `c` answer of `x` stays silent: the user picked it in a question.
|
||||||
|
**Consequences.** `X` asks more often, but only where it changes what is
|
||||||
|
recorded. Without staged pins or other losses it still runs at once.
|
||||||
|
|
||||||
|
## D32 — The screen explains itself
|
||||||
|
|
||||||
|
**Context.** The target user is a developer who doesn't know submodules
|
||||||
|
well and doesn't read manuals. The walkthrough found labels that don't say
|
||||||
|
what a key does (`x restore`, `X hard restore`), important keys found only
|
||||||
|
in `?` (`!`, `/`, `f`), and status words explained nowhere.
|
||||||
|
**Decision.**
|
||||||
|
- Labels name the target: `x restore to pin`, `X restore committed pins`.
|
||||||
|
"Hard" suggested "discards changes", which both do.
|
||||||
|
- A fourth key group, Tools (`!`, `/`, `f`), in the footer after the
|
||||||
|
navigation keys, dropped before them.
|
||||||
|
- On the header, `u` reads `pull (fast-forward)`: the label follows the
|
||||||
|
selection, the key doesn't.
|
||||||
|
- The full help gets titled columns, and a second page, a legend of the
|
||||||
|
status words, symbols and spine roles in their colors. Issue levels move
|
||||||
|
to `IssueKind.Level()` so the legend can't drift from the tree. `esc`
|
||||||
|
closes either page: cycling back with `?` alone was not obvious.
|
||||||
|
- A start hint above the footer until the first key.
|
||||||
|
- `fetched <age> · f fetch` in the detail pane when the last fetch is older
|
||||||
|
than a day.
|
||||||
|
**Alternatives.** A first-run screen or tutorial: needs state, and is
|
||||||
|
skipped. Contextual palettes that grey out impossible keys: larger, kept for
|
||||||
|
later (item 10 of the usability plan).
|
||||||
|
**Consequences.** `footer.Keys` drops `help.KeyMap` for `Tools`,
|
||||||
|
`HelpGroups` and `LegendKeys`; the footer renders the full help itself.
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 420 KiB After Width: | Height: | Size: 143 KiB |
+13
-2
@@ -145,8 +145,13 @@ commit hashes. It doesn't answer the questions users actually have:
|
|||||||
the header is selected): its worktree becomes exactly what git records, and
|
the header is selected): its worktree becomes exactly what git records, and
|
||||||
so do all submodules below it, recursively. Staged pins are kept at every
|
so do all submodules below it, recursively. Staged pins are kept at every
|
||||||
depth. For a submodule with a staged pin it asks whether to restore to the
|
depth. For a submodule with a staged pin it asks whether to restore to the
|
||||||
staged or the committed pin (the latter unstages it). It asks for
|
staged or the committed pin (the latter unstages it). A restored submodule
|
||||||
|
whose pin is a branch tip ends on that branch, not detached. It asks for
|
||||||
confirmation only if something would be lost. See [actions.md](actions.md#restore).
|
confirmation only if something would be lost. See [actions.md](actions.md#restore).
|
||||||
|
`X` is a hard restore: it goes to the committed pins at every depth,
|
||||||
|
without the pin question, and drops all staged pins below the selection
|
||||||
|
too. Dropped staged pins count as a loss, so it asks first. See
|
||||||
|
[actions.md](actions.md#hard-restore).
|
||||||
|
|
||||||
- **F57** `b` opens a popup in the middle of the screen with a search line
|
- **F57** `b` opens a popup in the middle of the screen with a search line
|
||||||
and the local and remote branches of the selection (a submodule, or the
|
and the local and remote branches of the selection (a submodule, or the
|
||||||
@@ -155,7 +160,13 @@ commit hashes. It doesn't answer the questions users actually have:
|
|||||||
with everything below; HEAD ends on the branch. It asks for confirmation
|
with everything below; HEAD ends on the branch. It asks for confirmation
|
||||||
only if something would be lost. See [actions.md](actions.md#switch-branch).
|
only if something would be lost. See [actions.md](actions.md#switch-branch).
|
||||||
|
|
||||||
Stage pin is drafted in [actions.md](actions.md#stage-pin).
|
- **F59** `u` updates the pin of the selection to the tip of its ref, when
|
||||||
|
the pin is only behind it (`origin/main ↓2`): a switch (**F57**) to that
|
||||||
|
branch, with exactly the tip the row shows. For the superproject that is
|
||||||
|
a fast-forward of its branch to its upstream (`git pull --ff-only`). Nothing is fetched; if
|
||||||
|
the ref moved meanwhile, nothing happens. A local branch that is only
|
||||||
|
behind is fast-forwarded, in a switch too. Otherwise the footer says why
|
||||||
|
not. See [actions.md](actions.md#update-pin).
|
||||||
|
|
||||||
## Usage
|
## Usage
|
||||||
|
|
||||||
|
|||||||
+69
-19
@@ -26,7 +26,7 @@
|
|||||||
my_submodule_3 unpushed origin/main ↑1 │ ┊ 1 commit
|
my_submodule_3 unpushed origin/main ↑1 │ ┊ 1 commit
|
||||||
libs/ │ tip ● 6f5e4d3 Release 2.1.0
|
libs/ │ tip ● 6f5e4d3 Release 2.1.0
|
||||||
logging origin/main ↓3 │
|
logging origin/main ↓3 │
|
||||||
↑↓/jk move • ←→/hl fold • ⇧←→/HL fold all • space toggle • ? help • q quit
|
↑↓/jk move • ←→/hl collapse/expand • ⇧←→/HL all • space toggle • ! problems only • / filter • f fetch ? help • q quit
|
||||||
```
|
```
|
||||||
|
|
||||||
| Area | Height / width |
|
| Area | Height / width |
|
||||||
@@ -34,26 +34,56 @@
|
|||||||
| Header | 1 line |
|
| Header | 1 line |
|
||||||
| Tree | Remaining width |
|
| Tree | Remaining width |
|
||||||
| Detail pane | 40% of the width, at least 36 columns; hidden below 90 columns |
|
| Detail pane | 40% of the width, at least 36 columns; hidden below 90 columns |
|
||||||
| Filter line | 1 line above the footer, only while filtering |
|
| Line above the footer | 1 line, only while filtering, showing problems only, during a restore, after a failure, or until the first key (hint) |
|
||||||
| Footer | 1 line (more with full help) |
|
| Footer | 1 line (more with full help) |
|
||||||
|
|
||||||
### Key bar and palette
|
### Key bar and palette
|
||||||
|
|
||||||
Keys come in three groups (`keyMap.Navigation`, `.Submodule`, `.Global`):
|
Keys come in four groups (`keyMap.Navigation`, `.Tools`, `.Submodule`,
|
||||||
|
`.Global`):
|
||||||
|
|
||||||
- **Navigation** (move, fold, toggle): left in the footer.
|
- **Navigation** (move, collapse/expand, toggle): left in the footer.
|
||||||
|
- **Tools** (`!` problems only, `/` filter, `f` fetch): in the footer after
|
||||||
|
the navigation keys, most useful first. They act on the whole list and
|
||||||
|
would otherwise be found only in the full help (D32).
|
||||||
- **Global** (`?`, `q`): right-aligned in the footer, always kept. If a newer
|
- **Global** (`?`, `q`): right-aligned in the footer, always kept. If a newer
|
||||||
release exists, `q quit and update to vX` follows them as the right-most
|
release exists, `q quit and update to vX` follows them as the right-most
|
||||||
item, on a highlighted background (`Theme.Highlight`: black on Primary), and
|
item, on a highlighted background (`Theme.Highlight`: black on Primary), and
|
||||||
plain quit is `Q` (see D27).
|
plain quit is `Q` (see D27).
|
||||||
- **Submodule** (act on the selected one, e.g. `c`): a palette on the last
|
- **Submodule** (act on the selected one, e.g. `c`): a palette at the
|
||||||
line of the detail pane. It is left out if the pane is shorter than 12
|
bottom of the detail pane, wrapped onto a second line if the pane is too
|
||||||
lines. Whenever the palette isn't shown (pane hidden or too short), these
|
narrow for all keys (none is cut off). It is left out if the pane is
|
||||||
keys move into the footer after the navigation keys.
|
shorter than 12 lines. Whenever the palette isn't shown (pane hidden or too short), these
|
||||||
|
keys move into the footer after the tools. With the header selected, `u`
|
||||||
|
reads `u pull (fast-forward)`, which is what it does there (D29).
|
||||||
|
|
||||||
If the footer is too narrow, keys are dropped from the left group's end
|
If the footer is too narrow, keys are dropped from the left group's end
|
||||||
(submodule keys first); the global keys stay. Full help (`?`) lists all keys
|
(submodule keys first, then the tools); the global keys stay. Keys work
|
||||||
in columns, whatever the size. Keys work whether or not they are shown.
|
whether or not they are shown.
|
||||||
|
|
||||||
|
### Full help
|
||||||
|
|
||||||
|
`?` steps through two pages, then back to the key bar; `esc` closes either
|
||||||
|
page at once (before it would close the filter):
|
||||||
|
|
||||||
|
1. **Keys**: every key in titled columns: `Move`, `Jump`, `Selected
|
||||||
|
submodule`, `All submodules`, `App`. The `App` column says `? what words
|
||||||
|
mean` and `esc close`.
|
||||||
|
2. **Legend**: what the words and symbols mean, in their colors: `Status`
|
||||||
|
(each issue word, `staged`, `in sync`), `Symbols` (`●`, `↑n ↓n`, `+n`,
|
||||||
|
`·k`, `[n]`), `Commits` (the spine's roles). Then `App` with `esc close`
|
||||||
|
(`?` closes it too).
|
||||||
|
The words come from `Theme.Legend()`; issue colors from
|
||||||
|
`IssueKind.Level()`, so they are the tree's.
|
||||||
|
|
||||||
|
Titles are muted and bold. Columns that don't fit are left out from the
|
||||||
|
right, marked with `…`; the `App` column is always kept.
|
||||||
|
|
||||||
|
### Start hint
|
||||||
|
|
||||||
|
Until the first key, the line above the footer reads muted `new here? ?
|
||||||
|
shows every key and what the words mean`. Every start, no state is kept.
|
||||||
|
Any other line above the footer (filter, restore, …) takes precedence.
|
||||||
|
|
||||||
### Tree row
|
### Tree row
|
||||||
|
|
||||||
@@ -84,7 +114,9 @@ For the superproject (header selected), the `pin` and `you` roles become
|
|||||||
|
|
||||||
1. Directory (muted) and name (bold); `fetched <age>` of the submodule,
|
1. Directory (muted) and name (bold); `fetched <age>` of the submodule,
|
||||||
muted, right-aligned in the name's line (left out if the pane is too
|
muted, right-aligned in the name's line (left out if the pane is too
|
||||||
narrow, or the submodule is missing)
|
narrow, or the submodule is missing). If the last fetch is older than a
|
||||||
|
day, or there was none, it continues `· f fetch` (key bold), if there is
|
||||||
|
room
|
||||||
2. One line per issue: word (colored, 11 columns) + explanation;
|
2. One line per issue: word (colored, 11 columns) + explanation;
|
||||||
`in sync nothing to do` if there are none and no pin is staged;
|
`in sync nothing to do` if there are none and no pin is staged;
|
||||||
`staged` and `detached HEAD on pin` as information
|
`staged` and `detached HEAD on pin` as information
|
||||||
@@ -212,7 +244,7 @@ shows the superproject's own state word like a tree row (`in sync`,
|
|||||||
`changed`, `unpushed +1`, spinner, `error`). The counts that follow are
|
`changed`, `unpushed +1`, spinner, `error`). The counts that follow are
|
||||||
about submodules only.
|
about submodules only.
|
||||||
|
|
||||||
`path branch ↑↓ state n error · <count> <issue>… · n pin staged · n loading · n submodules · <fetch>`
|
`path branch ↑↓ state n error · <count> <issue>… · n staged · n loading · n submodules · <fetch>`
|
||||||
|
|
||||||
The summary and `<fetch>` form one group at the right edge; the path,
|
The summary and `<fetch>` form one group at the right edge; the path,
|
||||||
branch and state stay at the left. If the line is too narrow it is cut with
|
branch and state stay at the left. If the line is too narrow it is cut with
|
||||||
@@ -286,7 +318,8 @@ colors in components.
|
|||||||
| `H` / `shift+←` / `ctrl+←` | Collapse all (resets remembered expansion); select the top-level ancestor |
|
| `H` / `shift+←` / `ctrl+←` | Collapse all (resets remembered expansion); select the top-level ancestor |
|
||||||
| `home` / `g`, `end` / `G` | Header (superproject) / last submodule |
|
| `home` / `g`, `end` / `G` | Header (superproject) / last submodule |
|
||||||
| `pgup`, `pgdn` | Move by one screen |
|
| `pgup`, `pgdn` | Move by one screen |
|
||||||
| `?` | Toggle full help |
|
| `?` | Full help: keys, then the legend, then closed, see [Full help](#full-help) |
|
||||||
|
| `esc` | Close the full help; else close the filter |
|
||||||
| `q`, `ctrl+c` | Quit. With a newer release: `q` quits and updates, `Q` and `ctrl+c` quit |
|
| `q`, `ctrl+c` | Quit. With a newer release: `q` quits and updates, `Q` and `ctrl+c` quit |
|
||||||
| `f` | Fetch all repositories (does nothing while a fetch runs) |
|
| `f` | Fetch all repositories (does nothing while a fetch runs) |
|
||||||
| `r` | Reload everything (e.g. after editing files) |
|
| `r` | Reload everything (e.g. after editing files) |
|
||||||
@@ -294,6 +327,7 @@ colors in components.
|
|||||||
| `/` | Open the filter, see [Filter](#filter) |
|
| `/` | Open the filter, see [Filter](#filter) |
|
||||||
| `!` | Toggle problems only, see [Problems only](#problems-only) |
|
| `!` | Toggle problems only, see [Problems only](#problems-only) |
|
||||||
| `x` | Restore the selection, see [Restore](#restore) |
|
| `x` | Restore the selection, see [Restore](#restore) |
|
||||||
|
| `X` | Restore the selection to the committed pins, staged pins dropped at every depth, see [Restore](#restore) |
|
||||||
|
|
||||||
"First child" always means the first child **as displayed** (children are
|
"First child" always means the first child **as displayed** (children are
|
||||||
grouped by directory).
|
grouped by directory).
|
||||||
@@ -360,7 +394,10 @@ the term*.
|
|||||||
| failed | red `restore of src/app failed: <git's message>`, until the next key |
|
| failed | red `restore of src/app failed: <git's message>`, until the next key |
|
||||||
|
|
||||||
`y` restores, any other key cancels; the keys are not passed on while a
|
`y` restores, any other key cancels; the keys are not passed on while a
|
||||||
question is open. After `c` the name reads `src/app to the committed pin`. Without a loss there is no question. Success flashes
|
question is open. After `c` the name reads `src/app to the committed pin`.
|
||||||
|
`X` (restore committed pins) never asks for the pin; its name reads
|
||||||
|
`src/app to the committed pins`. Staged pins it drops count as a loss
|
||||||
|
(`Discards 1 staged pin.`), so it asks (D31). Without a loss there is no question. Success flashes
|
||||||
`restored src/app` in the footer. For the header the name is `the
|
`restored src/app` in the footer. For the header the name is `the
|
||||||
superproject`. This line takes precedence over the filter line and the
|
superproject`. This line takes precedence over the filter line and the
|
||||||
problems-only notice.
|
problems-only notice.
|
||||||
@@ -393,6 +430,16 @@ than `Muted`, so the popup has the focus.
|
|||||||
[restore](#restore): `checking`, the question `Switch src/app to
|
[restore](#restore): `checking`, the question `Switch src/app to
|
||||||
origin/feat? Discards … y switch · n cancel`, `switching …`.
|
origin/feat? Discards … y switch · n cancel`, `switching …`.
|
||||||
|
|
||||||
|
### Update pin
|
||||||
|
|
||||||
|
`u` runs without a popup, on the line above the footer like a
|
||||||
|
[switch](#switch-branch): `checking src/app…`, the question `Update src/app
|
||||||
|
to origin/main? Discards … y update · n cancel` only if something would be
|
||||||
|
lost, `updating src/app to origin/main…`, then the flash `updated src/app to
|
||||||
|
main`. On the header the name is `the superproject` (`updated the
|
||||||
|
superproject to main`). Where it can't update, a flash says why (see
|
||||||
|
[actions.md](actions.md#keys-and-palette)).
|
||||||
|
|
||||||
### Key reference
|
### Key reference
|
||||||
|
|
||||||
Every key in the UI, where it is shown. Update this
|
Every key in the UI, where it is shown. Update this
|
||||||
@@ -410,13 +457,16 @@ where the palette can't be shown, see [Key bar and palette](#key-bar-and-palette
|
|||||||
| `home` / `g`, `end` / `G` | Header / last submodule | Navigation | Help |
|
| `home` / `g`, `end` / `G` | Header / last submodule | Navigation | Help |
|
||||||
| `pgup`, `pgdn` | Move by one screen | Navigation | Help |
|
| `pgup`, `pgdn` | Move by one screen | Navigation | Help |
|
||||||
| `c` | Copy path | Submodule | Palette, Help |
|
| `c` | Copy path | Submodule | Palette, Help |
|
||||||
|
| `u` | Update pin to the tip of its ref (superproject: fast-forward to upstream, label `pull (fast-forward)`) | Submodule | Palette, Help |
|
||||||
| `b` | Switch branch (popup) | Submodule | Palette, Help |
|
| `b` | Switch branch (popup) | Submodule | Palette, Help |
|
||||||
| `x` | Restore | Submodule | Palette, Help |
|
| `x` | Restore to pin | Submodule | Palette, Help |
|
||||||
| `f` | Fetch all repositories | Global | Help |
|
| `X` | Restore committed pins | Submodule | Palette, Help |
|
||||||
|
| `!` | Problems only (toggle) | Tools | Bar, Help |
|
||||||
|
| `/` | Filter | Tools | Bar, Help |
|
||||||
|
| `f` | Fetch all repositories | Tools | Bar, Help |
|
||||||
| `r` | Reload everything | Global | Help |
|
| `r` | Reload everything | Global | Help |
|
||||||
| `/` | Filter | Global | Help |
|
| `?` | Full help: keys, legend, close | Global | Bar, Help |
|
||||||
| `!` | Problems only (toggle) | Global | Help |
|
| `esc` | Close the full help (and the filter, see [Filter](#filter)) | Global | Help |
|
||||||
| `?` | Toggle full help | Global | Bar, Help |
|
|
||||||
| `q`, `ctrl+c` | Quit (`Q`, `ctrl+c` while an update is offered) | Global | Bar, Help |
|
| `q`, `ctrl+c` | Quit (`Q`, `ctrl+c` while an update is offered) | Global | Bar, Help |
|
||||||
| `q` | Quit and update to the newer release (only if there is one) | Global | Bar (highlighted), Help |
|
| `q` | Quit and update to the newer release (only if there is one) | Global | Bar (highlighted), Help |
|
||||||
|
|
||||||
|
|||||||
@@ -327,17 +327,10 @@ func pinState(ctx context.Context, in info, st *submodule.Status, pin, head, bra
|
|||||||
return fmt.Errorf("pinned commit %.7s not fetched", pin)
|
return fmt.Errorf("pinned commit %.7s not fetched", pin)
|
||||||
}
|
}
|
||||||
|
|
||||||
// remote branches containing the pin, oldest tip first
|
containing, err := remoteContaining(ctx, in.dir, pin)
|
||||||
out, err := run(ctx, in.dir, "for-each-ref", "--contains", pin, "--sort=committerdate", "--format=%(refname)", "refs/remotes")
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
var containing []string
|
|
||||||
for _, r := range strings.Fields(out) {
|
|
||||||
if !strings.HasSuffix(r, "/HEAD") {
|
|
||||||
containing = append(containing, strings.TrimPrefix(r, "refs/remotes/"))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
st.Pin.Pushed = len(containing) > 0
|
st.Pin.Pushed = len(containing) > 0
|
||||||
|
|
||||||
var ref string
|
var ref string
|
||||||
@@ -359,6 +352,7 @@ func pinState(ctx context.Context, in info, st *submodule.Status, pin, head, bra
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
st.CheckedOut = st.Pinned
|
st.CheckedOut = st.Pinned
|
||||||
|
var out string
|
||||||
if head != pin {
|
if head != pin {
|
||||||
if st.CheckedOut, err = commit(ctx, in.dir, head); err != nil {
|
if st.CheckedOut, err = commit(ctx, in.dir, head); err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -417,6 +411,22 @@ func pinState(ctx context.Context, in info, st *submodule.Status, pin, head, bra
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// remoteContaining lists the remote branches containing pin, oldest tip
|
||||||
|
// first, without the origin/HEAD symref.
|
||||||
|
func remoteContaining(ctx context.Context, dir, pin string) ([]string, error) {
|
||||||
|
out, err := run(ctx, dir, "for-each-ref", "--contains", pin, "--sort=committerdate", "--format=%(refname)", "refs/remotes")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var containing []string
|
||||||
|
for _, r := range strings.Fields(out) {
|
||||||
|
if !strings.HasSuffix(r, "/HEAD") {
|
||||||
|
containing = append(containing, strings.TrimPrefix(r, "refs/remotes/"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return containing, nil
|
||||||
|
}
|
||||||
|
|
||||||
// resolve finds the ref the pin is compared with, following the order in
|
// resolve finds the ref the pin is compared with, following the order in
|
||||||
// docs/state-model.md: the target (see targetRef) if the pin is on it, else the
|
// docs/state-model.md: the target (see targetRef) if the pin is on it, else the
|
||||||
// branch containing the pin that is closest to it, else the target anyway,
|
// branch containing the pin that is closest to it, else the target anyway,
|
||||||
|
|||||||
@@ -6,7 +6,9 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"io/fs"
|
"io/fs"
|
||||||
"os"
|
"os"
|
||||||
|
"path"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
@@ -33,19 +35,39 @@ func (p *Provider) RestoreLoss(ctx context.Context, path string, to submodule.Re
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return submodule.Loss{}, err
|
return submodule.Loss{}, err
|
||||||
}
|
}
|
||||||
if dir == "" { // not initialized: nothing on disk to lose
|
var loss submodule.Loss
|
||||||
return submodule.Loss{}, nil
|
if dir != "" { // not initialized: nothing on disk to lose
|
||||||
|
loss = p.lossOf(ctx, dir, target, to.KeepsStaged())
|
||||||
}
|
}
|
||||||
return p.lossOf(ctx, dir, target), nil
|
if to == submodule.RestoreHard && path != "" && p.pinStaged(ctx, path, target) {
|
||||||
|
// a hard restore unstages the submodule's own pin too
|
||||||
|
loss.StagedPins++
|
||||||
|
loss.Modules = max(loss.Modules, 1)
|
||||||
|
}
|
||||||
|
return loss, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// pinStaged reports whether the pin of path in its parent's index differs
|
||||||
|
// from committed, the pin a hard restore goes to.
|
||||||
|
func (p *Provider) pinStaged(ctx context.Context, path, committed string) bool {
|
||||||
|
p.mu.RLock()
|
||||||
|
in, ok := p.mods[path]
|
||||||
|
p.mu.RUnlock()
|
||||||
|
if !ok {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
staged, err := pinOf(ctx, in, submodule.RestoreStaged)
|
||||||
|
return err == nil && staged != committed
|
||||||
}
|
}
|
||||||
|
|
||||||
// Restore makes path ("" is the superproject) and all submodules below it
|
// Restore makes path ("" is the superproject) and all submodules below it
|
||||||
// exactly what git records, see docs/actions.md. Staged pins are kept. With
|
// exactly what git records, see docs/actions.md. Staged pins are kept. With
|
||||||
// RestoreCommitted, a submodule's own staged pin is unstaged first. A missing
|
// RestoreCommitted, a submodule's own staged pin is unstaged first; with
|
||||||
// submodule is cloned and missing commits are fetched; git never prompts.
|
// RestoreHard, the staged pins below are dropped too. A missing submodule is
|
||||||
|
// cloned and missing commits are fetched; git never prompts.
|
||||||
func (p *Provider) Restore(ctx context.Context, path string, to submodule.RestoreTarget) error {
|
func (p *Provider) Restore(ctx context.Context, path string, to submodule.RestoreTarget) error {
|
||||||
if path == "" {
|
if path == "" {
|
||||||
return p.restoreRepo(ctx, p.root, "HEAD")
|
return p.restoreRepo(ctx, p.root, "HEAD", nil, to.KeepsStaged())
|
||||||
}
|
}
|
||||||
p.mu.RLock()
|
p.mu.RLock()
|
||||||
in, ok := p.mods[path]
|
in, ok := p.mods[path]
|
||||||
@@ -57,7 +79,7 @@ func (p *Provider) Restore(ctx context.Context, path string, to submodule.Restor
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if to == submodule.RestoreCommitted {
|
if to.Committed() {
|
||||||
if _, err := p.exec(ctx, in.superDir, nil, "update-index", "--cacheinfo", "160000,"+pin+","+in.relPath); err != nil {
|
if _, err := p.exec(ctx, in.superDir, nil, "update-index", "--cacheinfo", "160000,"+pin+","+in.relPath); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -67,7 +89,7 @@ func (p *Provider) Restore(ctx context.Context, path string, to submodule.Restor
|
|||||||
return fmt.Errorf("%s: %w", path, err)
|
return fmt.Errorf("%s: %w", path, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return p.restoreRepo(ctx, in.dir, pin)
|
return p.restoreRepo(ctx, in.dir, pin, &in, to.KeepsStaged())
|
||||||
}
|
}
|
||||||
|
|
||||||
// restoreTarget returns the directory and the commit path is restored to:
|
// restoreTarget returns the directory and the commit path is restored to:
|
||||||
@@ -94,9 +116,10 @@ func (p *Provider) restoreTarget(ctx context.Context, path string, to submodule.
|
|||||||
}
|
}
|
||||||
|
|
||||||
// pinOf returns the gitlink of a submodule in its parent's index (staged)
|
// pinOf returns the gitlink of a submodule in its parent's index (staged)
|
||||||
// or HEAD (committed).
|
// or HEAD (committed). A hard restore of a submodule that was added but not
|
||||||
|
// committed yet uses the index: there is no staged pin to drop.
|
||||||
func pinOf(ctx context.Context, in info, to submodule.RestoreTarget) (string, error) {
|
func pinOf(ctx context.Context, in info, to submodule.RestoreTarget) (string, error) {
|
||||||
if to == submodule.RestoreCommitted {
|
if to.Committed() {
|
||||||
out, _, err := try(ctx, in.superDir, "ls-tree", "HEAD", "--", in.relPath)
|
out, _, err := try(ctx, in.superDir, "ls-tree", "HEAD", "--", in.relPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
@@ -104,7 +127,9 @@ func pinOf(ctx context.Context, in info, to submodule.RestoreTarget) (string, er
|
|||||||
if pin := parseGitlink(out); pin != "" {
|
if pin := parseGitlink(out); pin != "" {
|
||||||
return pin, nil
|
return pin, nil
|
||||||
}
|
}
|
||||||
return "", errors.New("has no committed pin (added, but not committed yet)")
|
if to != submodule.RestoreHard {
|
||||||
|
return "", errors.New("has no committed pin (added, but not committed yet)")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
out, err := run(ctx, in.superDir, "ls-files", "-s", "--", in.relPath)
|
out, err := run(ctx, in.superDir, "ls-files", "-s", "--", in.relPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -122,8 +147,11 @@ func pinOf(ctx context.Context, in info, to submodule.RestoreTarget) (string, er
|
|||||||
// restoreRepo restores the initialized repository at dir to target (a commit
|
// restoreRepo restores the initialized repository at dir to target (a commit
|
||||||
// or "HEAD") and then every submodule its index records. Branches are never
|
// or "HEAD") and then every submodule its index records. Branches are never
|
||||||
// moved: a checkout that is not on target is detached first. If the checkout
|
// moved: a checkout that is not on target is detached first. If the checkout
|
||||||
// stays on target, its staged pins are kept and are the submodules' targets.
|
// stays on target and keep is set, its staged pins are kept and are the
|
||||||
func (p *Provider) restoreRepo(ctx context.Context, dir, target string) error {
|
// submodules' targets; else the submodules follow target's tree.
|
||||||
|
// For a submodule (in is not nil) a detached HEAD is then put on a branch at
|
||||||
|
// target if there is one, see attachBranch; the submodules below always are.
|
||||||
|
func (p *Provider) restoreRepo(ctx context.Context, dir, target string, in *info, keep bool) error {
|
||||||
env := p.networkEnv()
|
env := p.networkEnv()
|
||||||
g := func(args ...string) (string, error) {
|
g := func(args ...string) (string, error) {
|
||||||
return p.exec(ctx, dir, env, append(append([]string{}, restoreArgs...), args...)...)
|
return p.exec(ctx, dir, env, append(append([]string{}, restoreArgs...), args...)...)
|
||||||
@@ -138,18 +166,21 @@ func (p *Provider) restoreRepo(ctx context.Context, dir, target string) error {
|
|||||||
if gitDir, _, err := gitDirs(dir); err == nil {
|
if gitDir, _, err := gitDirs(dir); err == nil {
|
||||||
p.clearOperations(ctx, dir, gitDir)
|
p.clearOperations(ctx, dir, gitDir)
|
||||||
}
|
}
|
||||||
var keep []gitlink
|
prev, _, _ := try(ctx, dir, "symbolic-ref", "--short", "-q", "HEAD") // for the ref, see attachBranch
|
||||||
|
var kept []gitlink
|
||||||
if head, err := g("rev-parse", "--verify", "-q", "HEAD"); err != nil || head != sha {
|
if head, err := g("rev-parse", "--verify", "-q", "HEAD"); err != nil || head != sha {
|
||||||
if _, err := g("checkout", "--force", "--detach", sha); err != nil {
|
if _, err := g("checkout", "--force", "--detach", sha); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
} else if keep, err = stagedPins(ctx, dir); err != nil {
|
} else if keep {
|
||||||
return err
|
if kept, err = stagedPins(ctx, dir); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if _, err := g("reset", "--hard", "-q"); err != nil {
|
if _, err := g("reset", "--hard", "-q"); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
for _, l := range keep {
|
for _, l := range kept {
|
||||||
if _, err := g("update-index", "--cacheinfo", "160000,"+l.sha+","+l.path); err != nil {
|
if _, err := g("update-index", "--cacheinfo", "160000,"+l.sha+","+l.path); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -161,6 +192,11 @@ func (p *Provider) restoreRepo(ctx context.Context, dir, target string) error {
|
|||||||
if _, err := g("submodule", "sync", "-q"); err != nil {
|
if _, err := g("submodule", "sync", "-q"); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
if in != nil {
|
||||||
|
if err := p.attachBranch(ctx, *in, sha, prev); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
links, err := indexGitlinks(ctx, dir)
|
links, err := indexGitlinks(ctx, dir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -178,6 +214,16 @@ func (p *Provider) restoreRepo(ctx context.Context, dir, target string) error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// the .gitmodules branch of each child, for its ref
|
||||||
|
branches := map[string]string{}
|
||||||
|
if out, ok, err := try(ctx, dir, "config", "-f", ".gitmodules", "--get-regexp", `^submodule\..*\.(path|branch)$`); err != nil {
|
||||||
|
return err
|
||||||
|
} else if ok {
|
||||||
|
for _, m := range parseGitmodules(out) {
|
||||||
|
branches[path.Clean(m.path)] = m.branch
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// the children are independent repositories
|
// the children are independent repositories
|
||||||
var wg sync.WaitGroup
|
var wg sync.WaitGroup
|
||||||
errs := make([]error, len(links))
|
errs := make([]error, len(links))
|
||||||
@@ -185,7 +231,8 @@ func (p *Provider) restoreRepo(ctx context.Context, dir, target string) error {
|
|||||||
wg.Add(1)
|
wg.Add(1)
|
||||||
go func() {
|
go func() {
|
||||||
defer wg.Done()
|
defer wg.Done()
|
||||||
if err := p.restoreRepo(ctx, filepath.Join(dir, filepath.FromSlash(l.path)), l.sha); err != nil {
|
child := info{superDir: dir, dir: filepath.Join(dir, filepath.FromSlash(l.path)), relPath: l.path, branch: branches[l.path]}
|
||||||
|
if err := p.restoreRepo(ctx, child.dir, l.sha, &child, keep); err != nil {
|
||||||
errs[i] = err
|
errs[i] = err
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
@@ -194,6 +241,135 @@ func (p *Provider) restoreRepo(ctx context.Context, dir, target string) error {
|
|||||||
return errors.Join(errs...)
|
return errors.Join(errs...)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// attachBranch puts the detached HEAD of the restored submodule in on a
|
||||||
|
// branch whose tip is sha, the first that git accepts of, in this order:
|
||||||
|
//
|
||||||
|
// 1. a local branch tracking the ref the UI shows (see resolve), or named
|
||||||
|
// like it;
|
||||||
|
// 2. the ref itself, as a new local branch tracking it, if no local branch
|
||||||
|
// has its name;
|
||||||
|
// 3. any other local branch, alphabetically;
|
||||||
|
// 4. any other remote branch whose name no local branch has, as a new
|
||||||
|
// tracking branch; the ref's remote first, then alphabetically.
|
||||||
|
//
|
||||||
|
// A HEAD that is on a branch stays there. No branch is moved: a local branch
|
||||||
|
// elsewhere is no candidate, and its remote branch neither. If nothing fits,
|
||||||
|
// HEAD stays detached; only a context error is returned. prev is the branch
|
||||||
|
// checked out before the restore; the ref depends on it.
|
||||||
|
func (p *Provider) attachBranch(ctx context.Context, in info, sha, prev string) error {
|
||||||
|
if _, onBranch, err := try(ctx, in.dir, "symbolic-ref", "-q", "HEAD"); err != nil || onBranch {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
out, ok, err := try(ctx, in.dir, "for-each-ref", "--points-at="+sha,
|
||||||
|
"--format=%(refname)%00%(upstream)%00%(symref)", "refs/heads", "refs/remotes")
|
||||||
|
if err != nil || !ok {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
var locals, remotes []string // alphabetical, as for-each-ref sorts them
|
||||||
|
upstreams := map[string]string{}
|
||||||
|
for _, line := range strings.Split(out, "\n") {
|
||||||
|
f := strings.Split(line, "\x00")
|
||||||
|
if len(f) != 3 || f[2] != "" { // a symref is origin/HEAD
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if name, ok := strings.CutPrefix(f[0], "refs/heads/"); ok {
|
||||||
|
locals = append(locals, name)
|
||||||
|
upstreams[name] = strings.TrimPrefix(f[1], "refs/remotes/")
|
||||||
|
} else if name, ok := strings.CutPrefix(f[0], "refs/remotes/"); ok {
|
||||||
|
remotes = append(remotes, name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(locals)+len(remotes) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// the ref, if it is a remote branch
|
||||||
|
var ref string
|
||||||
|
containing, err := remoteContaining(ctx, in.dir, sha)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if r, source, _, err := resolve(ctx, in, sha, prev, containing); err != nil {
|
||||||
|
return err
|
||||||
|
} else if source != submodule.RefFromTag && source != submodule.RefFromSHA {
|
||||||
|
ref = r
|
||||||
|
}
|
||||||
|
|
||||||
|
localOf := func(remote string) string { _, l, _ := strings.Cut(remote, "/"); return l }
|
||||||
|
hasLocal := func(name string) (bool, error) {
|
||||||
|
_, ok, err := try(ctx, in.dir, "rev-parse", "--verify", "-q", "refs/heads/"+name)
|
||||||
|
return ok, err
|
||||||
|
}
|
||||||
|
type candidate struct{ local, remote string } // remote: create local, tracking it
|
||||||
|
var cands []candidate
|
||||||
|
addRemote := func(r string) error {
|
||||||
|
l := localOf(r)
|
||||||
|
if l == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
exists, err := hasLocal(l)
|
||||||
|
if err == nil && !exists {
|
||||||
|
cands = append(cands, candidate{l, r})
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if ref != "" {
|
||||||
|
for _, l := range locals {
|
||||||
|
if upstreams[l] == ref {
|
||||||
|
cands = append(cands, candidate{local: l})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if l := localOf(ref); slices.Contains(locals, l) {
|
||||||
|
cands = append(cands, candidate{local: l})
|
||||||
|
}
|
||||||
|
if slices.Contains(remotes, ref) {
|
||||||
|
if err := addRemote(ref); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, l := range locals {
|
||||||
|
cands = append(cands, candidate{local: l})
|
||||||
|
}
|
||||||
|
preferred, _, _ := strings.Cut(ref, "/")
|
||||||
|
if preferred == "" {
|
||||||
|
if preferred, err = remoteName(ctx, in.dir); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
slices.SortStableFunc(remotes, func(a, b string) int {
|
||||||
|
ap, bp := strings.HasPrefix(a, preferred+"/"), strings.HasPrefix(b, preferred+"/")
|
||||||
|
switch {
|
||||||
|
case ap && !bp:
|
||||||
|
return -1
|
||||||
|
case bp && !ap:
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
})
|
||||||
|
for _, r := range remotes {
|
||||||
|
if r != ref {
|
||||||
|
if err := addRemote(r); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, c := range cands {
|
||||||
|
args := append(append([]string{}, restoreArgs...), "switch", "-q", c.local)
|
||||||
|
if c.remote != "" {
|
||||||
|
args = append(append([]string{}, restoreArgs...), "switch", "-q", "-c", c.local, "--track", c.remote)
|
||||||
|
}
|
||||||
|
if _, err := p.exec(ctx, in.dir, nil, args...); err == nil {
|
||||||
|
return nil
|
||||||
|
} else if ctx.Err() != nil {
|
||||||
|
return ctx.Err()
|
||||||
|
}
|
||||||
|
// e.g. checked out in another worktree: try the next one
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// networkEnv is the environment of commands that may clone or fetch.
|
// networkEnv is the environment of commands that may clone or fetch.
|
||||||
func (p *Provider) networkEnv() []string {
|
func (p *Provider) networkEnv() []string {
|
||||||
return append(append([]string{}, p.sshEnv...), p.extraEnv...)
|
return append(append([]string{}, p.sshEnv...), p.extraEnv...)
|
||||||
@@ -332,8 +508,9 @@ func stagedPins(ctx context.Context, dir string) ([]gitlink, error) {
|
|||||||
|
|
||||||
// lossOf adds up what a restore of the repository at dir to target
|
// lossOf adds up what a restore of the repository at dir to target
|
||||||
// discards, including the submodules on disk below it. target is "" if it is
|
// discards, including the submodules on disk below it. target is "" if it is
|
||||||
// not known; then commits are only judged by the refs that reach them.
|
// not known; then commits are only judged by the refs that reach them. keep
|
||||||
func (p *Provider) lossOf(ctx context.Context, dir, target string) submodule.Loss {
|
// is false for a hard restore, which drops all staged pins.
|
||||||
|
func (p *Provider) lossOf(ctx context.Context, dir, target string, keep bool) submodule.Loss {
|
||||||
var own submodule.Loss
|
var own submodule.Loss
|
||||||
var children []gitlink
|
var children []gitlink
|
||||||
var targets map[string]string
|
var targets map[string]string
|
||||||
@@ -395,16 +572,19 @@ func (p *Provider) lossOf(ctx context.Context, dir, target string) submodule.Los
|
|||||||
}
|
}
|
||||||
|
|
||||||
// staged pins are kept, unless the checkout moves away from
|
// staged pins are kept, unless the checkout moves away from
|
||||||
// the commit they were staged on; then the submodules follow
|
// the commit they were staged on or the restore is hard; then
|
||||||
// the target's tree
|
// the submodules follow the target's tree
|
||||||
staged, err := stagedPins(ctx, dir)
|
staged, err := stagedPins(ctx, dir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
unknown()
|
unknown()
|
||||||
}
|
}
|
||||||
var links []gitlink
|
var links []gitlink
|
||||||
switch {
|
switch {
|
||||||
case !moves:
|
case !moves && keep:
|
||||||
links, err = indexGitlinks(ctx, dir)
|
links, err = indexGitlinks(ctx, dir)
|
||||||
|
case !moves:
|
||||||
|
own.StagedPins = len(staged)
|
||||||
|
links, err = treeGitlinks(ctx, dir, "HEAD")
|
||||||
case target != "":
|
case target != "":
|
||||||
own.StagedPins = len(staged)
|
own.StagedPins = len(staged)
|
||||||
links, err = treeGitlinks(ctx, dir, target)
|
links, err = treeGitlinks(ctx, dir, target)
|
||||||
@@ -439,7 +619,7 @@ func (p *Provider) lossOf(ctx context.Context, dir, target string) submodule.Los
|
|||||||
wg.Add(1)
|
wg.Add(1)
|
||||||
go func() {
|
go func() {
|
||||||
defer wg.Done()
|
defer wg.Done()
|
||||||
results[i] = p.lossOf(ctx, sub, targets[c.path])
|
results[i] = p.lossOf(ctx, sub, targets[c.path], keep)
|
||||||
}()
|
}()
|
||||||
}
|
}
|
||||||
wg.Wait()
|
wg.Wait()
|
||||||
|
|||||||
@@ -310,6 +310,83 @@ func TestRestoreCommittedUnstagesThePin(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestRestoreHardDropsStagedPinsAtEveryDepth(t *testing.T) {
|
||||||
|
p, root := ownFixture(t)
|
||||||
|
super := filepath.Join(root, "super")
|
||||||
|
log := filepath.Join(super, "libs", "log")
|
||||||
|
nested := filepath.Join(log, "modules", "nested")
|
||||||
|
committed := out(t, nested, "rev-parse", "HEAD")
|
||||||
|
base, err := p.RestoreLoss(context.Background(), "", submodule.RestoreHard)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// stage a new pin of nested in libs/log, on a commit only nested's
|
||||||
|
// detached HEAD reaches
|
||||||
|
git(t, nested, "checkout", "-q", "--detach")
|
||||||
|
git(t, nested, "commit", "-q", "--allow-empty", "-m", "Nested work")
|
||||||
|
git(t, log, "add", "modules/nested")
|
||||||
|
|
||||||
|
l, err := p.RestoreLoss(context.Background(), "", submodule.RestoreHard)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if base.StagedPins == 0 || l.StagedPins != base.StagedPins+1 || l.Commits != base.Commits+1 {
|
||||||
|
t.Errorf("loss %+v, want the fixture's %d staged pins, the nested one and its commit", l, base.StagedPins)
|
||||||
|
}
|
||||||
|
if err := p.Restore(context.Background(), "", submodule.RestoreHard); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, dir := range []string{super, log} {
|
||||||
|
if got := out(t, dir, "diff", "--cached", "--name-only"); got != "" {
|
||||||
|
t.Errorf("%s still staged: %q", dir, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if got := out(t, nested, "rev-parse", "HEAD"); got != committed {
|
||||||
|
t.Errorf("nested HEAD = %s, want the committed pin %s", got, committed)
|
||||||
|
}
|
||||||
|
assertExact(t, super)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRestoreHardOfASubmoduleUnstagesItsPin(t *testing.T) {
|
||||||
|
p, root := ownFixture(t)
|
||||||
|
super := filepath.Join(root, "super")
|
||||||
|
committed := strings.Fields(out(t, super, "ls-tree", "HEAD", "libs/staged"))[2]
|
||||||
|
|
||||||
|
if err := p.Restore(context.Background(), "libs/staged", submodule.RestoreHard); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got := out(t, super, "diff", "--cached", "--name-only"); strings.Contains(got, "libs/staged") {
|
||||||
|
t.Errorf("still staged: %q", got)
|
||||||
|
}
|
||||||
|
if got := out(t, filepath.Join(super, "libs", "staged"), "rev-parse", "HEAD"); got != committed {
|
||||||
|
t.Errorf("HEAD = %s, want the committed pin %s", got, committed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRestoreLossOfAHardRestoreCountsTheOwnStagedPin(t *testing.T) {
|
||||||
|
p, _ := ownFixture(t)
|
||||||
|
committed, err := p.RestoreLoss(context.Background(), "libs/staged", submodule.RestoreCommitted)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
hard, err := p.RestoreLoss(context.Background(), "libs/staged", submodule.RestoreHard)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if hard.StagedPins != committed.StagedPins+1 || hard.Modules == 0 {
|
||||||
|
t.Errorf("hard loss %+v, want the own staged pin on top of %+v", hard, committed)
|
||||||
|
}
|
||||||
|
// without a staged pin, a hard restore unstages nothing of its own
|
||||||
|
clean, err := p.RestoreLoss(context.Background(), "src/ui", submodule.RestoreHard)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if clean.StagedPins != 0 {
|
||||||
|
t.Errorf("loss of src/ui %+v, want no staged pin", clean)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestRestoreLossCountsStagedPinsOfAMovingRepository(t *testing.T) {
|
func TestRestoreLossCountsStagedPinsOfAMovingRepository(t *testing.T) {
|
||||||
p, root := ownFixture(t)
|
p, root := ownFixture(t)
|
||||||
super := filepath.Join(root, "super")
|
super := filepath.Join(root, "super")
|
||||||
@@ -332,3 +409,130 @@ func TestRestoreLossCountsStagedPinsOfAMovingRepository(t *testing.T) {
|
|||||||
t.Errorf("loss %+v, want 1 staged pin and the 2 detached nested commits", l)
|
t.Errorf("loss %+v, want 1 staged pin and the 2 detached nested commits", l)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// restoreSub restores the submodule path and returns the branch its HEAD is
|
||||||
|
// on ("" if detached).
|
||||||
|
func restoreSub(t *testing.T, p *Provider, super, path string) string {
|
||||||
|
t.Helper()
|
||||||
|
if err := p.Restore(context.Background(), path, submodule.RestoreStaged); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return out(t, filepath.Join(super, filepath.FromSlash(path)), "branch", "--show-current")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRestoreAttachesTheLocalBranchTrackingTheRef(t *testing.T) {
|
||||||
|
p, root := ownFixture(t)
|
||||||
|
super := filepath.Join(root, "super")
|
||||||
|
// src/ui: in sync, the pin is behind origin/main, local main is at the pin
|
||||||
|
git(t, filepath.Join(super, "src", "ui"), "checkout", "-q", "--detach")
|
||||||
|
git(t, filepath.Join(super, "src", "ui"), "branch", "aaa") // alphabetically first, but no ref
|
||||||
|
|
||||||
|
if got := restoreSub(t, p, super, "src/ui"); got != "main" {
|
||||||
|
t.Errorf("branch = %q, want main", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRestoreCreatesTheRefBranchBeforeOtherBranches(t *testing.T) {
|
||||||
|
p, root := ownFixture(t)
|
||||||
|
super := filepath.Join(root, "super")
|
||||||
|
log := filepath.Join(super, "libs", "log")
|
||||||
|
// libs/log: the pin is the tip of origin/main and origin/archive
|
||||||
|
git(t, log, "checkout", "-q", "--detach")
|
||||||
|
git(t, log, "branch", "-D", "main")
|
||||||
|
|
||||||
|
if got := restoreSub(t, p, super, "libs/log"); got != "main" {
|
||||||
|
t.Errorf("branch = %q, want main", got)
|
||||||
|
}
|
||||||
|
if up := out(t, log, "rev-parse", "--abbrev-ref", "main@{upstream}"); up != "origin/main" {
|
||||||
|
t.Errorf("upstream = %q, want origin/main", up)
|
||||||
|
}
|
||||||
|
if out(t, log, "branch", "--list", "archive") != "" {
|
||||||
|
t.Error("archive must not be created")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRestoreCreatesARemoteFeatureBranch(t *testing.T) {
|
||||||
|
p, root := ownFixture(t)
|
||||||
|
super := filepath.Join(root, "super")
|
||||||
|
topic := filepath.Join(super, "libs", "topic")
|
||||||
|
mainTip := out(t, topic, "rev-parse", "main")
|
||||||
|
// libs/topic: detached at the tip of origin/feat/search, local main elsewhere
|
||||||
|
|
||||||
|
if got := restoreSub(t, p, super, "libs/topic"); got != "feat/search" {
|
||||||
|
t.Errorf("branch = %q, want feat/search", got)
|
||||||
|
}
|
||||||
|
if got := out(t, topic, "rev-parse", "main"); got != mainTip {
|
||||||
|
t.Errorf("main moved: %s -> %s", mainTip, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRestorePrefersLocalBranchesAlphabetically(t *testing.T) {
|
||||||
|
p, root := ownFixture(t)
|
||||||
|
super := filepath.Join(root, "super")
|
||||||
|
feature := filepath.Join(super, "libs", "feature")
|
||||||
|
// libs/feature: the pin is on no branch tip
|
||||||
|
git(t, feature, "branch", "zz")
|
||||||
|
git(t, feature, "branch", "aa")
|
||||||
|
|
||||||
|
if got := restoreSub(t, p, super, "libs/feature"); got != "aa" {
|
||||||
|
t.Errorf("branch = %q, want aa", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRestoreNeverMovesALocalBranchToAttach(t *testing.T) {
|
||||||
|
p, root := ownFixture(t)
|
||||||
|
super := filepath.Join(root, "super")
|
||||||
|
topic := filepath.Join(super, "libs", "topic")
|
||||||
|
git(t, topic, "branch", "feat/search", "origin/main") // same name, elsewhere
|
||||||
|
before := out(t, topic, "rev-parse", "feat/search")
|
||||||
|
|
||||||
|
if got := restoreSub(t, p, super, "libs/topic"); got != "" {
|
||||||
|
t.Errorf("branch = %q, want detached", got)
|
||||||
|
}
|
||||||
|
if after := out(t, topic, "rev-parse", "feat/search"); after != before {
|
||||||
|
t.Errorf("feat/search moved: %s -> %s", before, after)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRestoreKeepsTheCheckedOutBranch(t *testing.T) {
|
||||||
|
p, root := ownFixture(t)
|
||||||
|
super := filepath.Join(root, "super")
|
||||||
|
log := filepath.Join(super, "libs", "log")
|
||||||
|
git(t, log, "switch", "-q", "-c", "zz") // at the pin, like main
|
||||||
|
|
||||||
|
if got := restoreSub(t, p, super, "libs/log"); got != "zz" {
|
||||||
|
t.Errorf("branch = %q, want zz kept", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRestoreSkipsABranchOfAnotherWorktree(t *testing.T) {
|
||||||
|
p, root := ownFixture(t)
|
||||||
|
super := filepath.Join(root, "super")
|
||||||
|
log := filepath.Join(super, "libs", "log")
|
||||||
|
git(t, log, "checkout", "-q", "--detach")
|
||||||
|
git(t, log, "worktree", "add", "-q", filepath.Join(root, "wt"), "main")
|
||||||
|
|
||||||
|
if got := restoreSub(t, p, super, "libs/log"); got == "main" {
|
||||||
|
t.Error("main is checked out in another worktree")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRestoreSuperprojectAttachesNestedSubmodules(t *testing.T) {
|
||||||
|
p, root := ownFixture(t)
|
||||||
|
super := filepath.Join(root, "super")
|
||||||
|
nested := filepath.Join(super, "libs", "log", "modules", "nested")
|
||||||
|
git(t, nested, "checkout", "-q", "--detach")
|
||||||
|
|
||||||
|
if err := p.Restore(context.Background(), "", submodule.RestoreStaged); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got := out(t, nested, "branch", "--show-current"); got != "main" {
|
||||||
|
t.Errorf("nested branch = %q, want main", got)
|
||||||
|
}
|
||||||
|
if got := out(t, filepath.Join(super, "libs", "topic"), "branch", "--show-current"); got != "feat/search" {
|
||||||
|
t.Errorf("libs/topic branch = %q, want feat/search", got)
|
||||||
|
}
|
||||||
|
if got := out(t, super, "branch", "--show-current"); got != "main" {
|
||||||
|
t.Errorf("superproject branch = %q, want main", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -85,13 +85,16 @@ func (p *Provider) SwitchLoss(ctx context.Context, path string, b submodule.Bran
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return submodule.Loss{}, err
|
return submodule.Loss{}, err
|
||||||
}
|
}
|
||||||
return p.lossOf(ctx, dir, b.SHA), nil
|
return p.lossOf(ctx, dir, b.SHA, true), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Switch makes b's tip the staged pin of a submodule, restores the repository
|
// Switch makes b's tip the staged pin of a submodule, restores the repository
|
||||||
// to it (see Restore) and puts HEAD on the branch. For a remote branch the
|
// to it (see Restore) and puts HEAD on the branch. For a remote branch the
|
||||||
// local branch of that name is created, tracking it. A local branch that
|
// local branch is the one checked out before if it tracks b, else the one
|
||||||
// exists elsewhere is never moved: HEAD stays detached and "" is returned.
|
// of that name, created tracking b if there is none. A local branch that is
|
||||||
|
// only behind the tip is fast-forwarded; one with commits of its own, or
|
||||||
|
// checked out in another worktree, is never moved: HEAD stays detached and
|
||||||
|
// "" is returned.
|
||||||
func (p *Provider) Switch(ctx context.Context, path string, b submodule.Branch) (string, error) {
|
func (p *Provider) Switch(ctx context.Context, path string, b submodule.Branch) (string, error) {
|
||||||
dir, in, sub, err := p.switchDir(path)
|
dir, in, sub, err := p.switchDir(path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -102,12 +105,16 @@ func (p *Provider) Switch(ctx context.Context, path string, b submodule.Branch)
|
|||||||
} else if !ok || sha != b.SHA {
|
} else if !ok || sha != b.SHA {
|
||||||
return "", fmt.Errorf("%s moved; open the list again", b.Name)
|
return "", fmt.Errorf("%s moved; open the list again", b.Name)
|
||||||
}
|
}
|
||||||
|
prev, _, err := try(ctx, dir, "symbolic-ref", "--short", "-q", "HEAD")
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
if sub { // staged first, so a failed restore can be repeated with x
|
if sub { // staged first, so a failed restore can be repeated with x
|
||||||
if _, err := p.exec(ctx, in.superDir, nil, "update-index", "--cacheinfo", "160000,"+b.SHA+","+in.relPath); err != nil {
|
if _, err := p.exec(ctx, in.superDir, nil, "update-index", "--cacheinfo", "160000,"+b.SHA+","+in.relPath); err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if err := p.restoreRepo(ctx, dir, b.SHA); err != nil {
|
if err := p.restoreRepo(ctx, dir, b.SHA, nil, true); err != nil { // HEAD goes on b below
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -118,15 +125,51 @@ func (p *Provider) Switch(ctx context.Context, path string, b submodule.Branch)
|
|||||||
_, err := g("switch", "-q", b.Local)
|
_, err := g("switch", "-q", b.Local)
|
||||||
return b.Local, err
|
return b.Local, err
|
||||||
}
|
}
|
||||||
switch tip, ok, err := try(ctx, dir, "rev-parse", "--verify", "-q", "refs/heads/"+b.Local); {
|
local := b.Local
|
||||||
|
if prev != "" && prev != local {
|
||||||
|
if up, ok, err := upstream(ctx, dir, prev); err != nil {
|
||||||
|
return "", err
|
||||||
|
} else if ok && up == b.Name {
|
||||||
|
local = prev // e.g. dev tracking origin/main: an update stays on dev
|
||||||
|
}
|
||||||
|
}
|
||||||
|
switch tip, ok, err := try(ctx, dir, "rev-parse", "--verify", "-q", "refs/heads/"+local); {
|
||||||
case err != nil:
|
case err != nil:
|
||||||
return "", err
|
return "", err
|
||||||
case !ok:
|
case !ok:
|
||||||
_, err := g("switch", "-q", "-c", b.Local, "--track", b.Name)
|
_, err := g("switch", "-q", "-c", local, "--track", b.Name)
|
||||||
return b.Local, err
|
return local, err
|
||||||
case tip == b.SHA:
|
case tip == b.SHA:
|
||||||
_, err := g("switch", "-q", b.Local)
|
_, err := g("switch", "-q", local)
|
||||||
return b.Local, err
|
return local, err
|
||||||
|
default:
|
||||||
|
if ok, err := p.fastForward(ctx, dir, local, tip, b.SHA); err != nil || !ok {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
_, err := g("switch", "-q", local)
|
||||||
|
return local, err
|
||||||
}
|
}
|
||||||
return "", nil
|
}
|
||||||
|
|
||||||
|
// fastForward moves the local branch from tip to sha if that loses nothing:
|
||||||
|
// tip is an ancestor of sha and no worktree has the branch checked out. It
|
||||||
|
// reports whether the branch was moved; a branch that moved meanwhile is
|
||||||
|
// left alone.
|
||||||
|
func (p *Provider) fastForward(ctx context.Context, dir, local, tip, sha string) (bool, error) {
|
||||||
|
if _, ok, err := try(ctx, dir, "merge-base", "--is-ancestor", tip, sha); err != nil || !ok {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
wts, err := run(ctx, dir, "worktree", "list", "--porcelain")
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
for _, line := range strings.Split(wts, "\n") {
|
||||||
|
if line == "branch refs/heads/"+local {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if _, ok, err := try(ctx, dir, "update-ref", "-m", "submarine: fast-forward", "refs/heads/"+local, sha, tip); err != nil || !ok {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
return true, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -81,12 +81,15 @@ func TestSwitchSubmoduleStagesTipAndTracksRemoteBranch(t *testing.T) {
|
|||||||
assertExact(t, sub)
|
assertExact(t, sub)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestSwitchNeverMovesAnExistingLocalBranch(t *testing.T) {
|
func TestSwitchNeverMovesALocalBranchWithCommitsOfItsOwn(t *testing.T) {
|
||||||
p, root := ownFixture(t)
|
p, root := ownFixture(t)
|
||||||
sub := filepath.Join(root, "super", "libs", "feature")
|
sub := filepath.Join(root, "super", "libs", "feature")
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
git(t, sub, "branch", "feat/search", "origin/main") // same name, elsewhere
|
// same name, diverged: a commit of its own on top of origin/main
|
||||||
|
git(t, sub, "switch", "-q", "-c", "feat/search", "origin/main")
|
||||||
|
git(t, sub, "commit", "-q", "--allow-empty", "-m", "own")
|
||||||
|
git(t, sub, "switch", "-q", "--detach", "HEAD~1")
|
||||||
before := out(t, sub, "rev-parse", "feat/search")
|
before := out(t, sub, "rev-parse", "feat/search")
|
||||||
bs, err := p.Branches(ctx, "libs/feature")
|
bs, err := p.Branches(ctx, "libs/feature")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -111,6 +114,112 @@ func TestSwitchNeverMovesAnExistingLocalBranch(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestSwitchFastForwardsALocalBranchThatIsBehind(t *testing.T) {
|
||||||
|
p, root := ownFixture(t)
|
||||||
|
sub := filepath.Join(root, "super", "libs", "feature")
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
git(t, sub, "branch", "feat/search", "origin/feat/search~1") // behind only
|
||||||
|
bs, err := p.Branches(ctx, "libs/feature")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
b := findBranch(t, bs, "origin/feat/search")
|
||||||
|
got, err := p.Switch(ctx, "libs/feature", b)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got != "feat/search" || out(t, sub, "branch", "--show-current") != "feat/search" {
|
||||||
|
t.Errorf("Switch = %q, branch = %q; want feat/search", got, out(t, sub, "branch", "--show-current"))
|
||||||
|
}
|
||||||
|
if tip := out(t, sub, "rev-parse", "feat/search"); tip != b.SHA {
|
||||||
|
t.Errorf("feat/search = %s, want fast-forwarded to %s", tip, b.SHA)
|
||||||
|
}
|
||||||
|
assertExact(t, sub)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSwitchSuperprojectFastForwardsItsBranch(t *testing.T) {
|
||||||
|
p, root := ownFixture(t)
|
||||||
|
super := filepath.Join(root, "super")
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
// main, checked out, is one behind origin/main: a pull --ff-only
|
||||||
|
tip := out(t, super, "rev-parse", "HEAD")
|
||||||
|
git(t, super, "update-ref", "refs/remotes/origin/main", tip)
|
||||||
|
git(t, super, "update-ref", "refs/heads/main", tip+"~1")
|
||||||
|
bs, err := p.Branches(ctx, "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
got, err := p.Switch(ctx, "", findBranch(t, bs, "origin/main"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got != "main" || out(t, super, "branch", "--show-current") != "main" {
|
||||||
|
t.Errorf("Switch = %q, branch = %q; want main", got, out(t, super, "branch", "--show-current"))
|
||||||
|
}
|
||||||
|
if head := out(t, super, "rev-parse", "main"); head != tip {
|
||||||
|
t.Errorf("main = %s, want fast-forwarded to %s", head, tip)
|
||||||
|
}
|
||||||
|
assertExact(t, super)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSwitchStaysOnTheCheckedOutBranchThatTracksIt(t *testing.T) {
|
||||||
|
p, root := ownFixture(t)
|
||||||
|
super := filepath.Join(root, "super")
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
// dev, checked out, tracks origin/main and is one behind it
|
||||||
|
tip := out(t, super, "rev-parse", "HEAD")
|
||||||
|
git(t, super, "remote", "add", "origin", filepath.Join(root, "nowhere")) // never contacted
|
||||||
|
git(t, super, "update-ref", "refs/remotes/origin/main", tip)
|
||||||
|
git(t, super, "switch", "-q", "-c", "dev")
|
||||||
|
git(t, super, "update-ref", "refs/heads/dev", tip+"~1")
|
||||||
|
git(t, super, "branch", "-q", "--set-upstream-to=origin/main", "dev")
|
||||||
|
bs, err := p.Branches(ctx, "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
got, err := p.Switch(ctx, "", findBranch(t, bs, "origin/main"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got != "dev" || out(t, super, "branch", "--show-current") != "dev" {
|
||||||
|
t.Errorf("Switch = %q, branch = %q; want dev", got, out(t, super, "branch", "--show-current"))
|
||||||
|
}
|
||||||
|
if head := out(t, super, "rev-parse", "dev"); head != tip {
|
||||||
|
t.Errorf("dev = %s, want fast-forwarded to %s", head, tip)
|
||||||
|
}
|
||||||
|
if main := out(t, super, "rev-parse", "main"); main != tip {
|
||||||
|
t.Errorf("main = %s, must not have moved from %s", main, tip)
|
||||||
|
}
|
||||||
|
assertExact(t, super)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSwitchNeverMovesABranchOfAnotherWorktree(t *testing.T) {
|
||||||
|
p, root := ownFixture(t)
|
||||||
|
sub := filepath.Join(root, "super", "libs", "feature")
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
git(t, sub, "branch", "feat/search", "origin/feat/search~1")
|
||||||
|
git(t, sub, "worktree", "add", "-q", filepath.Join(root, "wt"), "feat/search")
|
||||||
|
before := out(t, sub, "rev-parse", "feat/search")
|
||||||
|
bs, err := p.Branches(ctx, "libs/feature")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
got, err := p.Switch(ctx, "libs/feature", findBranch(t, bs, "origin/feat/search"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got != "" {
|
||||||
|
t.Errorf("Switch returned %q, want detached", got)
|
||||||
|
}
|
||||||
|
if after := out(t, sub, "rev-parse", "feat/search"); after != before {
|
||||||
|
t.Errorf("branch of the other worktree moved: %s -> %s", before, after)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestSwitchAbortsWhenTheBranchMoved(t *testing.T) {
|
func TestSwitchAbortsWhenTheBranchMoved(t *testing.T) {
|
||||||
p, root := ownFixture(t)
|
p, root := ownFixture(t)
|
||||||
sub := filepath.Join(root, "super", "libs", "feature")
|
sub := filepath.Join(root, "super", "libs", "feature")
|
||||||
|
|||||||
@@ -72,15 +72,24 @@ type Switcher interface {
|
|||||||
Switch(ctx context.Context, path string, b Branch) (string, error)
|
Switch(ctx context.Context, path string, b Branch) (string, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
// RestoreTarget is the pin a submodule is restored to. It doesn't matter for
|
// RestoreTarget is the pin a submodule is restored to, and whether staged
|
||||||
// the superproject, whose target is always HEAD.
|
// pins below it are kept. The superproject's target is always HEAD; only
|
||||||
|
// RestoreHard matters for it.
|
||||||
type RestoreTarget int
|
type RestoreTarget int
|
||||||
|
|
||||||
const (
|
const (
|
||||||
RestoreStaged RestoreTarget = iota // the pin in the parent's index: a staged pin is kept and used
|
RestoreStaged RestoreTarget = iota // the pin in the parent's index: a staged pin is kept and used
|
||||||
RestoreCommitted // the pin in the parent's HEAD: a staged pin is unstaged
|
RestoreCommitted // the pin in the parent's HEAD: a staged pin is unstaged
|
||||||
|
RestoreHard // the pin in the parent's HEAD, and staged pins below are dropped too
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Committed reports whether a submodule goes to the pin in its parent's HEAD.
|
||||||
|
func (t RestoreTarget) Committed() bool { return t != RestoreStaged }
|
||||||
|
|
||||||
|
// KeepsStaged reports whether staged pins below the restored repository are
|
||||||
|
// kept.
|
||||||
|
func (t RestoreTarget) KeepsStaged() bool { return t != RestoreHard }
|
||||||
|
|
||||||
// Loss is what a restore discards. The zero Loss means nothing is lost.
|
// Loss is what a restore discards. The zero Loss means nothing is lost.
|
||||||
type Loss struct {
|
type Loss struct {
|
||||||
Files int // changed, conflicted and untracked files and directories
|
Files int // changed, conflicted and untracked files and directories
|
||||||
|
|||||||
@@ -24,6 +24,21 @@ const (
|
|||||||
IssueOffTarget // pin pushed, but not on the target branch
|
IssueOffTarget // pin pushed, but not on the target branch
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Kinds lists every issue kind, most severe first.
|
||||||
|
var Kinds = []IssueKind{IssueUnpushed, IssueConflict, IssueMissing, IssueOffPin, IssueChanged, IssueOffTarget}
|
||||||
|
|
||||||
|
// Level is how much attention an issue of kind k deserves.
|
||||||
|
func (k IssueKind) Level() Level {
|
||||||
|
switch k {
|
||||||
|
case IssueUnpushed, IssueConflict:
|
||||||
|
return LevelDanger
|
||||||
|
case IssueOffTarget:
|
||||||
|
return LevelInfo
|
||||||
|
default:
|
||||||
|
return LevelWarn
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Issue is one problem of a submodule. Detail distinguishes the states that
|
// Issue is one problem of a submodule. Detail distinguishes the states that
|
||||||
// are summarized into one kind.
|
// are summarized into one kind.
|
||||||
type Issue struct {
|
type Issue struct {
|
||||||
@@ -40,9 +55,9 @@ func (s *Status) Issues() []Issue {
|
|||||||
}
|
}
|
||||||
switch s.Presence {
|
switch s.Presence {
|
||||||
case NotInitialized:
|
case NotInitialized:
|
||||||
return []Issue{{IssueMissing, LevelWarn, "not initialized"}}
|
return []Issue{{IssueMissing, IssueMissing.Level(), "not initialized"}}
|
||||||
case DirMissing:
|
case DirMissing:
|
||||||
return []Issue{{IssueMissing, LevelWarn, "directory missing"}}
|
return []Issue{{IssueMissing, IssueMissing.Level(), "directory missing"}}
|
||||||
}
|
}
|
||||||
|
|
||||||
var out []Issue
|
var out []Issue
|
||||||
@@ -51,24 +66,24 @@ func (s *Status) Issues() []Issue {
|
|||||||
if s.Root {
|
if s.Root {
|
||||||
detail = "HEAD not pushed to any remote"
|
detail = "HEAD not pushed to any remote"
|
||||||
}
|
}
|
||||||
out = append(out, Issue{IssueUnpushed, LevelDanger, detail})
|
out = append(out, Issue{IssueUnpushed, IssueUnpushed.Level(), detail})
|
||||||
}
|
}
|
||||||
if s.Worktree == WorktreeConflict {
|
if s.Worktree == WorktreeConflict {
|
||||||
out = append(out, Issue{IssueConflict, LevelDanger, "merge conflict"})
|
out = append(out, Issue{IssueConflict, IssueConflict.Level(), "merge conflict"})
|
||||||
}
|
}
|
||||||
if s.Checkout == CheckoutDiffers {
|
if s.Checkout == CheckoutDiffers {
|
||||||
out = append(out, Issue{IssueOffPin, LevelWarn, "checkout differs from pin"})
|
out = append(out, Issue{IssueOffPin, IssueOffPin.Level(), "checkout differs from pin"})
|
||||||
}
|
}
|
||||||
switch s.Worktree {
|
switch s.Worktree {
|
||||||
case WorktreeModified:
|
case WorktreeModified:
|
||||||
out = append(out, Issue{IssueChanged, LevelWarn, "uncommitted changes"})
|
out = append(out, Issue{IssueChanged, IssueChanged.Level(), "uncommitted changes"})
|
||||||
case WorktreeUntracked:
|
case WorktreeUntracked:
|
||||||
out = append(out, Issue{IssueChanged, LevelWarn, s.untrackedText()})
|
out = append(out, Issue{IssueChanged, IssueChanged.Level(), s.untrackedText()})
|
||||||
}
|
}
|
||||||
// a pin that is pushed, but not on the target (usually main); an
|
// a pin that is pushed, but not on the target (usually main); an
|
||||||
// unpushed pin is not on it either, but has the worse issue
|
// unpushed pin is not on it either, but has the worse issue
|
||||||
if !s.Root && s.Pin.Pushed && s.Pin.Target != "" && s.Pin.Ref != s.Pin.Target {
|
if !s.Root && s.Pin.Pushed && s.Pin.Target != "" && s.Pin.Ref != s.Pin.Target {
|
||||||
out = append(out, Issue{IssueOffTarget, LevelInfo, s.offTargetText()})
|
out = append(out, Issue{IssueOffTarget, IssueOffTarget.Level(), s.offTargetText()})
|
||||||
}
|
}
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|||||||
+29
-7
@@ -60,6 +60,7 @@ type Model struct {
|
|||||||
picker *branches.Model // the branch picker, nil if closed
|
picker *branches.Model // the branch picker, nil if closed
|
||||||
pickerPath string // the repository the picker is for
|
pickerPath string // the repository the picker is for
|
||||||
notice string // an error to show above the footer until the next key
|
notice string // an error to show above the footer until the next key
|
||||||
|
hint bool // the hint for newcomers above the footer, until the first key
|
||||||
|
|
||||||
checkUpdate func(context.Context) string // looks for a newer release, nil if off
|
checkUpdate func(context.Context) string // looks for a newer release, nil if off
|
||||||
update string // the newer release found, "" if none
|
update string // the newer release found, "" if none
|
||||||
@@ -104,6 +105,7 @@ func New(ctx context.Context, p submodule.Provider, opts ...Option) Model {
|
|||||||
footer: footer.New(t, k),
|
footer: footer.New(t, k),
|
||||||
spinner: sp,
|
spinner: sp,
|
||||||
filter: fi,
|
filter: fi,
|
||||||
|
hint: true,
|
||||||
}
|
}
|
||||||
m.fetcher, _ = p.(submodule.Fetcher)
|
m.fetcher, _ = p.(submodule.Fetcher)
|
||||||
m.watcher, _ = p.(submodule.Watcher)
|
m.watcher, _ = p.(submodule.Watcher)
|
||||||
@@ -385,14 +387,17 @@ func (m Model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|||||||
return m.answerRestore(msg)
|
return m.answerRestore(msg)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if m.notice != "" {
|
if m.notice != "" || m.hint {
|
||||||
m.notice = ""
|
m.notice, m.hint = "", false
|
||||||
m.layout()
|
m.layout()
|
||||||
}
|
}
|
||||||
if m.filter.Focused() {
|
if m.filter.Focused() {
|
||||||
return m.typeFilter(msg)
|
return m.typeFilter(msg)
|
||||||
}
|
}
|
||||||
switch {
|
switch {
|
||||||
|
case m.footer.HelpOpen() && msg.Type == tea.KeyEsc: // before the filter's esc
|
||||||
|
m.footer.CloseHelp()
|
||||||
|
m.layout()
|
||||||
case m.filtering && msg.Type == tea.KeyEsc:
|
case m.filtering && msg.Type == tea.KeyEsc:
|
||||||
m.closeFilter()
|
m.closeFilter()
|
||||||
case key.Matches(msg, k.Quit):
|
case key.Matches(msg, k.Quit):
|
||||||
@@ -401,7 +406,7 @@ func (m Model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|||||||
m.updateTo = m.update
|
m.updateTo = m.update
|
||||||
return m, tea.Quit
|
return m, tea.Quit
|
||||||
case key.Matches(msg, k.Help):
|
case key.Matches(msg, k.Help):
|
||||||
m.footer.ToggleHelp()
|
m.footer.CycleHelp()
|
||||||
m.layout()
|
m.layout()
|
||||||
case key.Matches(msg, k.Up):
|
case key.Matches(msg, k.Up):
|
||||||
m.tree.Up()
|
m.tree.Up()
|
||||||
@@ -448,9 +453,13 @@ func (m Model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|||||||
m.layout()
|
m.layout()
|
||||||
return m, m.filter.Focus()
|
return m, m.filter.Focus()
|
||||||
case key.Matches(msg, k.Restore):
|
case key.Matches(msg, k.Restore):
|
||||||
return m.startRestore()
|
return m.startRestore(false)
|
||||||
|
case key.Matches(msg, k.HardRestore):
|
||||||
|
return m.startRestore(true)
|
||||||
case key.Matches(msg, k.Switch):
|
case key.Matches(msg, k.Switch):
|
||||||
return m.startSwitch()
|
return m.startSwitch()
|
||||||
|
case key.Matches(msg, k.Update):
|
||||||
|
return m.startPinUpdate()
|
||||||
case key.Matches(msg, k.Issues):
|
case key.Matches(msg, k.Issues):
|
||||||
m.tree.SetProblemsOnly(!m.tree.ProblemsOnly())
|
m.tree.SetProblemsOnly(!m.tree.ProblemsOnly())
|
||||||
m.layout()
|
m.layout()
|
||||||
@@ -513,10 +522,16 @@ func (m Model) filterView() string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// extraLine reports whether a line above the footer is shown: a restore in
|
// extraLine reports whether a line above the footer is shown: a restore in
|
||||||
// progress, a failure, the filter, or the notice that only problems are
|
// progress, a failure, the filter, the notice that only problems are shown,
|
||||||
// shown.
|
// or the hint for newcomers.
|
||||||
func (m Model) extraLine() bool {
|
func (m Model) extraLine() bool {
|
||||||
return m.filtering || m.tree.ProblemsOnly() || m.restore != nil || m.notice != ""
|
return m.filtering || m.tree.ProblemsOnly() || m.restore != nil || m.notice != "" || m.hint
|
||||||
|
}
|
||||||
|
|
||||||
|
// hintView is the line above the footer until the first key: where help is.
|
||||||
|
func (m Model) hintView() string {
|
||||||
|
muted := theme.Fg(m.theme.Muted)
|
||||||
|
return m.line(muted.Render("new here? ")+theme.Fg(m.theme.Text).Bold(true).Render("?")+muted.Render(" shows every key and what the words mean"), "")
|
||||||
}
|
}
|
||||||
|
|
||||||
// problemsView is the line above the footer while only problems are shown, so
|
// problemsView is the line above the footer while only problems are shown, so
|
||||||
@@ -577,6 +592,11 @@ func (m Model) View() string {
|
|||||||
return theme.Fg(m.theme.Danger).Render("error: " + m.err.Error())
|
return theme.Fg(m.theme.Danger).Render("error: " + m.err.Error())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// the keys as they read for the selection; m is a copy
|
||||||
|
k := m.keys.forSelection(m.tree.RootSelected())
|
||||||
|
m.footer.SetKeys(k)
|
||||||
|
m.detail.SetActions(k.Submodule())
|
||||||
|
|
||||||
body := m.tree.View()
|
body := m.tree.View()
|
||||||
if m.showDetail {
|
if m.showDetail {
|
||||||
pane := lipgloss.NewStyle().
|
pane := lipgloss.NewStyle().
|
||||||
@@ -597,6 +617,8 @@ func (m Model) View() string {
|
|||||||
parts = append(parts, m.filterView())
|
parts = append(parts, m.filterView())
|
||||||
} else if m.tree.ProblemsOnly() {
|
} else if m.tree.ProblemsOnly() {
|
||||||
parts = append(parts, m.problemsView())
|
parts = append(parts, m.problemsView())
|
||||||
|
} else if m.hint {
|
||||||
|
parts = append(parts, m.hintView())
|
||||||
}
|
}
|
||||||
view := zone.Scan(lipgloss.JoinVertical(lipgloss.Left, append(parts, m.footer.View())...))
|
view := zone.Scan(lipgloss.JoinVertical(lipgloss.Left, append(parts, m.footer.View())...))
|
||||||
if m.picker != nil { // after zone.Scan, so no zone marker is cut
|
if m.picker != nil { // after zone.Scan, so no zone marker is cut
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"path"
|
"path"
|
||||||
"slices"
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/charmbracelet/bubbles/help"
|
"github.com/charmbracelet/bubbles/help"
|
||||||
"github.com/charmbracelet/bubbles/key"
|
"github.com/charmbracelet/bubbles/key"
|
||||||
@@ -18,6 +19,9 @@ import (
|
|||||||
|
|
||||||
const labelWidth = 11
|
const labelWidth = 11
|
||||||
|
|
||||||
|
// staleFetch is the age of the last fetch from which the pane suggests f.
|
||||||
|
const staleFetch = 24 * time.Hour
|
||||||
|
|
||||||
type Model struct {
|
type Model struct {
|
||||||
theme theme.Theme
|
theme theme.Theme
|
||||||
width int
|
width int
|
||||||
@@ -38,6 +42,9 @@ func New(t theme.Theme, actions []key.Binding) Model {
|
|||||||
// PaletteVisible reports whether the palette of submodule actions fits.
|
// PaletteVisible reports whether the palette of submodule actions fits.
|
||||||
func (m Model) PaletteVisible() bool { return m.height >= paletteMinHeight }
|
func (m Model) PaletteVisible() bool { return m.height >= paletteMinHeight }
|
||||||
|
|
||||||
|
// SetActions replaces the bindings of the palette.
|
||||||
|
func (m *Model) SetActions(actions []key.Binding) { m.actions = actions }
|
||||||
|
|
||||||
func (m *Model) SetSize(w, h int) { m.width, m.height = w, h }
|
func (m *Model) SetSize(w, h int) { m.width, m.height = w, h }
|
||||||
func (m *Model) SetSpinner(frame string) { m.spinner = frame }
|
func (m *Model) SetSpinner(frame string) { m.spinner = frame }
|
||||||
|
|
||||||
@@ -55,20 +62,41 @@ func (m Model) View(s *submodule.Submodule, repo *submodule.Repo) string {
|
|||||||
lines[i] = ansi.Truncate(l, m.width, "…")
|
lines[i] = ansi.Truncate(l, m.width, "…")
|
||||||
}
|
}
|
||||||
height := m.height
|
height := m.height
|
||||||
var palette string
|
var palette []string
|
||||||
if m.PaletteVisible() {
|
if m.PaletteVisible() {
|
||||||
height-- // the palette is the last line
|
palette = m.palette()
|
||||||
m.help.Width = 0
|
height -= len(palette) // the palette is at the bottom
|
||||||
palette = ansi.Truncate(m.help.ShortHelpView(m.actions), m.width, "…")
|
|
||||||
}
|
}
|
||||||
if len(lines) > height {
|
if len(lines) > height {
|
||||||
lines = lines[:height]
|
lines = lines[:height]
|
||||||
}
|
}
|
||||||
body := lipgloss.NewStyle().Width(m.width).Height(height).Render(strings.Join(lines, "\n"))
|
body := lipgloss.NewStyle().Width(m.width).Height(height).Render(strings.Join(lines, "\n"))
|
||||||
if palette == "" {
|
if len(palette) == 0 {
|
||||||
return body
|
return body
|
||||||
}
|
}
|
||||||
return body + "\n" + palette
|
return body + "\n" + strings.Join(palette, "\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
// palette lays the actions out on as many lines as the width needs, so no
|
||||||
|
// key is cut off.
|
||||||
|
func (m Model) palette() []string {
|
||||||
|
m.help.Width = 0
|
||||||
|
var out []string
|
||||||
|
var cur []key.Binding
|
||||||
|
for _, b := range m.actions {
|
||||||
|
if len(cur) > 0 && lipgloss.Width(m.help.ShortHelpView(append(cur, b))) > m.width {
|
||||||
|
out = append(out, m.help.ShortHelpView(cur))
|
||||||
|
cur = nil
|
||||||
|
}
|
||||||
|
cur = append(cur, b)
|
||||||
|
}
|
||||||
|
if len(cur) > 0 {
|
||||||
|
out = append(out, m.help.ShortHelpView(cur))
|
||||||
|
}
|
||||||
|
for i, l := range out {
|
||||||
|
out[i] = ansi.Truncate(l, m.width, "…")
|
||||||
|
}
|
||||||
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m Model) lines(s *submodule.Submodule, repo *submodule.Repo) []string {
|
func (m Model) lines(s *submodule.Submodule, repo *submodule.Repo) []string {
|
||||||
@@ -102,10 +130,17 @@ func (m Model) lines(s *submodule.Submodule, repo *submodule.Repo) []string {
|
|||||||
st := s.Status
|
st := s.Status
|
||||||
|
|
||||||
// the age of the last fetch is right-aligned in the name's line
|
// the age of the last fetch is right-aligned in the name's line
|
||||||
|
// with a hint to fetch if the last fetch is old, and room for it
|
||||||
if st.Presence == submodule.PresenceOK {
|
if st.Presence == submodule.PresenceOK {
|
||||||
fetched := "fetched " + theme.Ago(st.FetchedAt)
|
fetched := muted.Render("fetched " + theme.Ago(st.FetchedAt))
|
||||||
if gap := m.width - ansi.StringWidth(s.Name) - len(fetched); gap >= 2 {
|
if time.Since(st.FetchedAt) > staleFetch {
|
||||||
out[1] += strings.Repeat(" ", gap) + muted.Render(fetched)
|
hinted := fetched + muted.Render(" · ") + text.Bold(true).Render("f") + muted.Render(" fetch")
|
||||||
|
if m.width-ansi.StringWidth(s.Name)-lipgloss.Width(hinted) >= 2 {
|
||||||
|
fetched = hinted
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if gap := m.width - ansi.StringWidth(s.Name) - lipgloss.Width(fetched); gap >= 2 {
|
||||||
|
out[1] += strings.Repeat(" ", gap) + fetched
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
package detail
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/charmbracelet/x/ansi"
|
||||||
|
|
||||||
|
"submarine/internal/submodule"
|
||||||
|
"submarine/internal/tui/theme"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestFetchHintForOldFetches(t *testing.T) {
|
||||||
|
m := New(theme.Default(), nil)
|
||||||
|
m.SetSize(60, 20)
|
||||||
|
for _, c := range []struct {
|
||||||
|
fetched time.Time
|
||||||
|
hint bool
|
||||||
|
}{
|
||||||
|
{time.Time{}, true},
|
||||||
|
{time.Now().Add(-48 * time.Hour), true},
|
||||||
|
{time.Now().Add(-time.Hour), false},
|
||||||
|
} {
|
||||||
|
s := &submodule.Submodule{Path: "src/app", Name: "app", Status: &submodule.Status{
|
||||||
|
Presence: submodule.PresenceOK, FetchedAt: c.fetched, Pin: submodule.PinInfo{Pushed: true},
|
||||||
|
}}
|
||||||
|
got := ansi.Strip(m.View(s, nil))
|
||||||
|
if strings.Contains(got, "f fetch") != c.hint {
|
||||||
|
t.Errorf("fetched %s: hint %v, want %v:\n%s", theme.Ago(c.fetched), !c.hint, c.hint, got)
|
||||||
|
}
|
||||||
|
if !strings.Contains(got, "fetched "+theme.Ago(c.fetched)) {
|
||||||
|
t.Errorf("the age is missing:\n%s", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// too narrow for the hint: the age alone
|
||||||
|
m.SetSize(24, 20)
|
||||||
|
s := &submodule.Submodule{Path: "src/app", Name: "app", Status: &submodule.Status{Presence: submodule.PresenceOK}}
|
||||||
|
if got := ansi.Strip(m.View(s, nil)); strings.Contains(got, "f fetch") || !strings.Contains(got, "fetched never") {
|
||||||
|
t.Errorf("narrow pane:\n%s", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
package detail
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/charmbracelet/bubbles/key"
|
||||||
|
"github.com/charmbracelet/lipgloss"
|
||||||
|
|
||||||
|
"submarine/internal/tui/theme"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestPaletteWrapsInsteadOfCuttingKeys(t *testing.T) {
|
||||||
|
keys := []key.Binding{
|
||||||
|
key.NewBinding(key.WithKeys("c"), key.WithHelp("c", "copy path")),
|
||||||
|
key.NewBinding(key.WithKeys("u"), key.WithHelp("u", "update pin")),
|
||||||
|
key.NewBinding(key.WithKeys("b"), key.WithHelp("b", "switch branch")),
|
||||||
|
key.NewBinding(key.WithKeys("x"), key.WithHelp("x", "restore to pin")),
|
||||||
|
}
|
||||||
|
m := New(theme.Default(), keys)
|
||||||
|
m.SetSize(36, 20)
|
||||||
|
lines := m.palette()
|
||||||
|
if len(lines) != 2 {
|
||||||
|
t.Fatalf("want 2 lines, got %q", lines)
|
||||||
|
}
|
||||||
|
all := strings.Join(lines, "\n")
|
||||||
|
for _, want := range []string{"copy path", "update pin", "switch branch", "x restore to pin"} {
|
||||||
|
if !strings.Contains(all, want) {
|
||||||
|
t.Errorf("palette lacks %q: %q", want, lines)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, l := range lines {
|
||||||
|
if w := lipgloss.Width(l); w > 36 {
|
||||||
|
t.Errorf("line %q is %d wide", l, w)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
m.SetSize(120, 20)
|
||||||
|
if lines := m.palette(); len(lines) != 1 {
|
||||||
|
t.Errorf("wide pane: want 1 line, got %q", lines)
|
||||||
|
}
|
||||||
|
}
|
||||||
+132
-11
@@ -2,6 +2,7 @@
|
|||||||
package footer
|
package footer
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -18,18 +19,39 @@ const flashDuration = 2 * time.Second
|
|||||||
|
|
||||||
type clearFlashMsg struct{ id int }
|
type clearFlashMsg struct{ id int }
|
||||||
|
|
||||||
// Keys provides the key groups. The short bar shows Navigation (and
|
// Keys provides the key groups. The short bar shows Navigation, Tools (and
|
||||||
// Submodule, if requested) on the left, Global and then Highlight (on a
|
// Submodule, if requested) on the left, Global and then Highlight (on a
|
||||||
// highlighted background) on the right; full help
|
// highlighted background) on the right. The full help has two pages: the
|
||||||
// shows FullHelp.
|
// HelpGroups (the last one is always kept), then the theme's legend with
|
||||||
|
// LegendKeys next to it.
|
||||||
type Keys interface {
|
type Keys interface {
|
||||||
help.KeyMap
|
|
||||||
Navigation() []key.Binding
|
Navigation() []key.Binding
|
||||||
|
Tools() []key.Binding
|
||||||
Submodule() []key.Binding
|
Submodule() []key.Binding
|
||||||
Global() []key.Binding
|
Global() []key.Binding
|
||||||
Highlight() []key.Binding
|
Highlight() []key.Binding
|
||||||
|
HelpGroups() []Group
|
||||||
|
LegendKeys() Group
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Group is a titled column of the full help.
|
||||||
|
type Group struct {
|
||||||
|
Title string
|
||||||
|
Keys []key.Binding
|
||||||
|
}
|
||||||
|
|
||||||
|
// helpPage is what the footer shows besides a flash.
|
||||||
|
type helpPage int
|
||||||
|
|
||||||
|
const (
|
||||||
|
pageBar helpPage = iota // one line of key hints
|
||||||
|
pageKeys // every key, in titled columns
|
||||||
|
pageLegend // what the words and symbols mean
|
||||||
|
)
|
||||||
|
|
||||||
|
// columnGap separates the columns of the full help.
|
||||||
|
const columnGap = " "
|
||||||
|
|
||||||
type Model struct {
|
type Model struct {
|
||||||
theme theme.Theme
|
theme theme.Theme
|
||||||
help help.Model
|
help help.Model
|
||||||
@@ -37,6 +59,7 @@ type Model struct {
|
|||||||
width int
|
width int
|
||||||
flash string
|
flash string
|
||||||
flashID int
|
flashID int
|
||||||
|
page helpPage
|
||||||
actions bool // show the submodule bindings in the short bar
|
actions bool // show the submodule bindings in the short bar
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -56,8 +79,18 @@ func (m *Model) SetWidth(w int) {
|
|||||||
m.help.Width = w - 2
|
m.help.Width = w - 2
|
||||||
}
|
}
|
||||||
|
|
||||||
// ToggleHelp switches between short and full key help.
|
// CycleHelp shows the next help page: the short bar, every key, the legend,
|
||||||
func (m *Model) ToggleHelp() { m.help.ShowAll = !m.help.ShowAll }
|
// then the short bar again.
|
||||||
|
func (m *Model) CycleHelp() { m.page = (m.page + 1) % 3 }
|
||||||
|
|
||||||
|
// HelpOpen reports whether a full help page is shown.
|
||||||
|
func (m Model) HelpOpen() bool { return m.page != pageBar }
|
||||||
|
|
||||||
|
// CloseHelp goes back to the short bar from any help page.
|
||||||
|
func (m *Model) CloseHelp() { m.page = pageBar }
|
||||||
|
|
||||||
|
// Legend reports whether the legend is shown.
|
||||||
|
func (m Model) Legend() bool { return m.page == pageLegend }
|
||||||
|
|
||||||
// Flash shows msg for a short time instead of the key hints.
|
// Flash shows msg for a short time instead of the key hints.
|
||||||
func (m *Model) Flash(msg string) tea.Cmd {
|
func (m *Model) Flash(msg string) tea.Cmd {
|
||||||
@@ -78,14 +111,102 @@ func (m Model) View() string {
|
|||||||
if m.flash != "" {
|
if m.flash != "" {
|
||||||
return style.Foreground(m.theme.Warn).Render(m.flash)
|
return style.Foreground(m.theme.Warn).Render(m.flash)
|
||||||
}
|
}
|
||||||
if m.help.ShowAll {
|
switch m.page {
|
||||||
return style.Render(m.help.View(m.keys))
|
case pageKeys:
|
||||||
|
var cols []column
|
||||||
|
for _, g := range m.keys.HelpGroups() {
|
||||||
|
cols = append(cols, m.keyColumn(g))
|
||||||
|
}
|
||||||
|
return style.Render(m.columns(cols[:len(cols)-1], cols[len(cols)-1], m.width-2))
|
||||||
|
case pageLegend:
|
||||||
|
var cols []column
|
||||||
|
for _, g := range m.theme.Legend() {
|
||||||
|
c := column{title: g.Title}
|
||||||
|
for _, r := range g.Rows {
|
||||||
|
c.rows = append(c.rows, [2]string{r.Word, m.help.Styles.FullDesc.Render(r.Meaning)})
|
||||||
|
}
|
||||||
|
cols = append(cols, c)
|
||||||
|
}
|
||||||
|
return style.Render(m.columns(cols, m.keyColumn(m.keys.LegendKeys()), m.width-2))
|
||||||
}
|
}
|
||||||
return style.Render(m.shortBar(m.width - 2))
|
return style.Render(m.shortBar(m.width - 2))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// column is a titled column of the full help: rows of a rendered key (or
|
||||||
|
// word) and its description.
|
||||||
|
type column struct {
|
||||||
|
title string
|
||||||
|
rows [][2]string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Model) keyColumn(g Group) column {
|
||||||
|
c := column{title: g.Title}
|
||||||
|
for _, b := range g.Keys {
|
||||||
|
if b.Enabled() {
|
||||||
|
c.rows = append(c.rows, [2]string{m.help.Styles.FullKey.Render(b.Help().Key), m.help.Styles.FullDesc.Render(b.Help().Desc)})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
|
||||||
|
// columns lays cols out side by side, each under its title, and last at the
|
||||||
|
// end. Like the global keys in the short bar, last is always kept: the
|
||||||
|
// columns before it that don't fit into w are left out from the right,
|
||||||
|
// marked with an ellipsis.
|
||||||
|
func (m Model) columns(cols []column, last column, w int) string {
|
||||||
|
tail := m.block(last)
|
||||||
|
avail := w - lipgloss.Width(tail) - len(columnGap)
|
||||||
|
var out []string
|
||||||
|
width := 0
|
||||||
|
for i, c := range cols {
|
||||||
|
b := m.block(c)
|
||||||
|
bw := lipgloss.Width(b) + len(columnGap)
|
||||||
|
if i == 0 {
|
||||||
|
bw -= len(columnGap)
|
||||||
|
}
|
||||||
|
if width+bw > avail {
|
||||||
|
for len(out) > 0 && width+2 > avail { // room for the ellipsis
|
||||||
|
width -= lipgloss.Width(out[len(out)-1]) + len(columnGap)
|
||||||
|
out = out[:len(out)-1]
|
||||||
|
}
|
||||||
|
if width+2 <= avail {
|
||||||
|
out = append(out, " "+m.help.Styles.Ellipsis.Render("…"))
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
out = append(out, b)
|
||||||
|
width += bw
|
||||||
|
}
|
||||||
|
var parts []string
|
||||||
|
for i, b := range out {
|
||||||
|
if i > 0 && !strings.HasPrefix(b, " ") {
|
||||||
|
parts = append(parts, columnGap)
|
||||||
|
}
|
||||||
|
parts = append(parts, b)
|
||||||
|
}
|
||||||
|
if len(parts) > 0 {
|
||||||
|
parts = append(parts, columnGap)
|
||||||
|
}
|
||||||
|
parts = append(parts, tail)
|
||||||
|
return lipgloss.NewStyle().MaxWidth(w).Render(lipgloss.JoinHorizontal(lipgloss.Top, parts...))
|
||||||
|
}
|
||||||
|
|
||||||
|
// block renders a column: its title, then its rows with the descriptions
|
||||||
|
// aligned.
|
||||||
|
func (m Model) block(c column) string {
|
||||||
|
left := 0
|
||||||
|
for _, r := range c.rows {
|
||||||
|
left = max(left, lipgloss.Width(r[0]))
|
||||||
|
}
|
||||||
|
lines := []string{theme.Fg(m.theme.Muted).Bold(true).Render(c.title)}
|
||||||
|
for _, r := range c.rows {
|
||||||
|
lines = append(lines, r[0]+strings.Repeat(" ", left-lipgloss.Width(r[0])+1)+r[1])
|
||||||
|
}
|
||||||
|
return strings.Join(lines, "\n")
|
||||||
|
}
|
||||||
|
|
||||||
// shortBar puts the global keys on the right and as many of the others on the
|
// shortBar puts the global keys on the right and as many of the others on the
|
||||||
// left as fit; the submodule keys are dropped first.
|
// left as fit; the submodule keys are dropped first, then the tools.
|
||||||
func (m Model) shortBar(w int) string {
|
func (m Model) shortBar(w int) string {
|
||||||
h := m.help
|
h := m.help
|
||||||
h.Width = 0
|
h.Width = 0
|
||||||
@@ -97,9 +218,9 @@ func (m Model) shortBar(w int) string {
|
|||||||
}
|
}
|
||||||
right += " " + m.theme.Highlight().Render(" "+strings.Join(parts, " ")+" ")
|
right += " " + m.theme.Highlight().Render(" "+strings.Join(parts, " ")+" ")
|
||||||
}
|
}
|
||||||
left := m.keys.Navigation()
|
left := append(slices.Clip(m.keys.Navigation()), m.keys.Tools()...)
|
||||||
if m.actions {
|
if m.actions {
|
||||||
left = append(left[:len(left):len(left)], m.keys.Submodule()...)
|
left = append(left, m.keys.Submodule()...)
|
||||||
}
|
}
|
||||||
for ; ; left = left[:len(left)-1] {
|
for ; ; left = left[:len(left)-1] {
|
||||||
l := h.ShortHelpView(left)
|
l := h.ShortHelpView(left)
|
||||||
|
|||||||
@@ -15,14 +15,25 @@ type testKeys struct{ highlight []key.Binding }
|
|||||||
|
|
||||||
func bind(k, desc string) key.Binding { return key.NewBinding(key.WithKeys(k), key.WithHelp(k, desc)) }
|
func bind(k, desc string) key.Binding { return key.NewBinding(key.WithKeys(k), key.WithHelp(k, desc)) }
|
||||||
|
|
||||||
func (testKeys) ShortHelp() []key.Binding { return nil }
|
|
||||||
func (testKeys) FullHelp() [][]key.Binding { return nil }
|
|
||||||
func (testKeys) Navigation() []key.Binding {
|
func (testKeys) Navigation() []key.Binding {
|
||||||
return []key.Binding{bind("j", "move"), bind("h", "fold")}
|
return []key.Binding{bind("j", "move"), bind("h", "fold")}
|
||||||
}
|
}
|
||||||
func (testKeys) Submodule() []key.Binding { return nil }
|
func (testKeys) Tools() []key.Binding {
|
||||||
|
return []key.Binding{bind("!", "problems only"), bind("/", "filter"), bind("f", "fetch")}
|
||||||
|
}
|
||||||
|
func (testKeys) Submodule() []key.Binding { return []key.Binding{bind("x", "restore to pin")} }
|
||||||
func (testKeys) Global() []key.Binding { return []key.Binding{bind("?", "help"), bind("Q", "quit")} }
|
func (testKeys) Global() []key.Binding { return []key.Binding{bind("?", "help"), bind("Q", "quit")} }
|
||||||
func (k testKeys) Highlight() []key.Binding { return k.highlight }
|
func (k testKeys) Highlight() []key.Binding { return k.highlight }
|
||||||
|
func (testKeys) HelpGroups() []Group {
|
||||||
|
return []Group{
|
||||||
|
{Title: "Move", Keys: []key.Binding{bind("j", "down"), bind("k", "up")}},
|
||||||
|
{Title: "Selected submodule", Keys: []key.Binding{bind("x", "restore to pin")}},
|
||||||
|
{Title: "App", Keys: []key.Binding{bind("?", "what words mean"), bind("Q", "quit")}},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
func (testKeys) LegendKeys() Group {
|
||||||
|
return Group{Title: "App", Keys: []key.Binding{bind("esc", "close")}}
|
||||||
|
}
|
||||||
|
|
||||||
func bar(w int, hl ...key.Binding) string {
|
func bar(w int, hl ...key.Binding) string {
|
||||||
m := New(theme.Default(), testKeys{hl})
|
m := New(theme.Default(), testKeys{hl})
|
||||||
@@ -52,3 +63,77 @@ func TestNavigationGivesWayToUpdate(t *testing.T) {
|
|||||||
t.Errorf("the right group must be kept:\n%q", got)
|
t.Errorf("the right group must be kept:\n%q", got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestToolsFollowNavigation(t *testing.T) {
|
||||||
|
got := bar(80)
|
||||||
|
if !strings.Contains(got, "h fold • ! problems only • / filter • f fetch") {
|
||||||
|
t.Errorf("tools must follow the navigation keys:\n%q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestToolsGiveWayBeforeNavigation(t *testing.T) {
|
||||||
|
got := bar(40)
|
||||||
|
if strings.Contains(got, "f fetch") || !strings.Contains(got, "j move") {
|
||||||
|
t.Errorf("tools must go before navigation:\n%q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSubmoduleKeysGoFirst(t *testing.T) {
|
||||||
|
m := New(theme.Default(), testKeys{})
|
||||||
|
m.SetActions(true)
|
||||||
|
m.SetWidth(80)
|
||||||
|
got := ansi.Strip(m.View())
|
||||||
|
if strings.Contains(got, "restore to pin") || !strings.Contains(got, "f fetch") {
|
||||||
|
t.Errorf("submodule keys must go before the tools:\n%q", got)
|
||||||
|
}
|
||||||
|
m.SetWidth(120)
|
||||||
|
if got := ansi.Strip(m.View()); !strings.Contains(got, "f fetch • x restore to pin") {
|
||||||
|
t.Errorf("submodule keys follow the tools:\n%q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHelpPages(t *testing.T) {
|
||||||
|
m := New(theme.Default(), testKeys{})
|
||||||
|
m.SetWidth(120)
|
||||||
|
m.CycleHelp()
|
||||||
|
keys := ansi.Strip(m.View())
|
||||||
|
for _, want := range []string{"Move", "Selected submodule", "App", "x restore to pin", "? what words mean"} {
|
||||||
|
if !strings.Contains(keys, want) {
|
||||||
|
t.Errorf("key page lacks %q:\n%s", want, keys)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
m.CycleHelp()
|
||||||
|
if !m.Legend() {
|
||||||
|
t.Fatal("second page must be the legend")
|
||||||
|
}
|
||||||
|
legend := ansi.Strip(m.View())
|
||||||
|
for _, want := range []string{"Status", "off pin", "Symbols", "Commits", "esc close"} {
|
||||||
|
if !strings.Contains(legend, want) {
|
||||||
|
t.Errorf("legend lacks %q:\n%s", want, legend)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, l := range strings.Split(m.View(), "\n") {
|
||||||
|
if w := lipgloss.Width(l); w > 120 {
|
||||||
|
t.Errorf("legend line is %d wide: %q", w, ansi.Strip(l))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
m.CycleHelp()
|
||||||
|
if got := ansi.Strip(m.View()); !strings.Contains(got, "? help") {
|
||||||
|
t.Errorf("third ? must close the help:\n%q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHelpLeavesOutColumnsThatDontFit(t *testing.T) {
|
||||||
|
m := New(theme.Default(), testKeys{})
|
||||||
|
m.SetWidth(30)
|
||||||
|
m.CycleHelp()
|
||||||
|
got := ansi.Strip(m.View())
|
||||||
|
if !strings.Contains(got, "App") || strings.Contains(got, "Selected") || !strings.Contains(got, "…") {
|
||||||
|
t.Errorf("want App kept, the columns before it left out with an ellipsis:\n%s", got)
|
||||||
|
}
|
||||||
|
for _, l := range strings.Split(m.View(), "\n") {
|
||||||
|
if w := lipgloss.Width(l); w > 30 {
|
||||||
|
t.Errorf("line is %d wide: %q", w, ansi.Strip(l))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -72,16 +72,13 @@ func View(t theme.Theme, repo *submodule.Repo, fetch Fetch, spinner string, widt
|
|||||||
if errored > 0 {
|
if errored > 0 {
|
||||||
parts = append(parts, t.LevelStyle(submodule.LevelDanger, bar).Render(fmt.Sprintf("%d error", errored)))
|
parts = append(parts, t.LevelStyle(submodule.LevelDanger, bar).Render(fmt.Sprintf("%d error", errored)))
|
||||||
}
|
}
|
||||||
for _, k := range []submodule.IssueKind{
|
for _, k := range submodule.Kinds {
|
||||||
submodule.IssueUnpushed, submodule.IssueConflict, submodule.IssueMissing,
|
|
||||||
submodule.IssueOffPin, submodule.IssueChanged, submodule.IssueOffTarget,
|
|
||||||
} {
|
|
||||||
if n := perKind[k]; n > 0 {
|
if n := perKind[k]; n > 0 {
|
||||||
parts = append(parts, t.LevelStyle(level[k], bar).Render(fmt.Sprintf("%d %s", n, theme.IssueWord(k))))
|
parts = append(parts, t.LevelStyle(level[k], bar).Render(fmt.Sprintf("%d %s", n, theme.IssueWord(k))))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if staged > 0 {
|
if staged > 0 {
|
||||||
parts = append(parts, seg(t.Info, fmt.Sprintf("%d pin staged", staged)))
|
parts = append(parts, seg(t.Info, fmt.Sprintf("%d %s", staged, theme.Staged)))
|
||||||
}
|
}
|
||||||
if loading > 0 {
|
if loading > 0 {
|
||||||
parts = append(parts, seg(t.Muted, fmt.Sprintf("%d loading", loading)))
|
parts = append(parts, seg(t.Muted, fmt.Sprintf("%d loading", loading)))
|
||||||
|
|||||||
+41
-15
@@ -2,6 +2,8 @@ package tui
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"github.com/charmbracelet/bubbles/key"
|
"github.com/charmbracelet/bubbles/key"
|
||||||
|
|
||||||
|
"submarine/internal/tui/footer"
|
||||||
)
|
)
|
||||||
|
|
||||||
type keyMap struct {
|
type keyMap struct {
|
||||||
@@ -9,7 +11,8 @@ type keyMap struct {
|
|||||||
CollapseAll, ExpandAll key.Binding
|
CollapseAll, ExpandAll key.Binding
|
||||||
Home, End, PageUp, PageDown key.Binding
|
Home, End, PageUp, PageDown key.Binding
|
||||||
Fetch, Reload, Filter, Issues key.Binding
|
Fetch, Reload, Filter, Issues key.Binding
|
||||||
Copy, Restore, Switch key.Binding
|
Copy, Restore, Switch, Update key.Binding
|
||||||
|
HardRestore key.Binding
|
||||||
Help, Quit, QuitUpdate key.Binding
|
Help, Quit, QuitUpdate key.Binding
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -30,8 +33,10 @@ func defaultKeys() keyMap {
|
|||||||
Reload: key.NewBinding(key.WithKeys("r"), key.WithHelp("r", "reload")),
|
Reload: key.NewBinding(key.WithKeys("r"), key.WithHelp("r", "reload")),
|
||||||
Filter: key.NewBinding(key.WithKeys("/"), key.WithHelp("/", "filter")),
|
Filter: key.NewBinding(key.WithKeys("/"), key.WithHelp("/", "filter")),
|
||||||
Issues: key.NewBinding(key.WithKeys("!"), key.WithHelp("!", "problems only")),
|
Issues: key.NewBinding(key.WithKeys("!"), key.WithHelp("!", "problems only")),
|
||||||
Restore: key.NewBinding(key.WithKeys("x"), key.WithHelp("x", "restore")),
|
Restore: key.NewBinding(key.WithKeys("x"), key.WithHelp("x", "restore to pin")),
|
||||||
|
HardRestore: key.NewBinding(key.WithKeys("X"), key.WithHelp("X", "restore committed pins")),
|
||||||
Switch: key.NewBinding(key.WithKeys("b"), key.WithHelp("b", "switch branch")),
|
Switch: key.NewBinding(key.WithKeys("b"), key.WithHelp("b", "switch branch")),
|
||||||
|
Update: key.NewBinding(key.WithKeys("u"), key.WithHelp("u", "update pin")),
|
||||||
Copy: key.NewBinding(key.WithKeys("c"), key.WithHelp("c", "copy path")),
|
Copy: key.NewBinding(key.WithKeys("c"), key.WithHelp("c", "copy path")),
|
||||||
Help: key.NewBinding(key.WithKeys("?"), key.WithHelp("?", "help")),
|
Help: key.NewBinding(key.WithKeys("?"), key.WithHelp("?", "help")),
|
||||||
Quit: key.NewBinding(key.WithKeys("q", "ctrl+c"), key.WithHelp("q", "quit")),
|
Quit: key.NewBinding(key.WithKeys("q", "ctrl+c"), key.WithHelp("q", "quit")),
|
||||||
@@ -49,14 +54,21 @@ func (k *keyMap) SetUpdate(v string) {
|
|||||||
// Navigation returns the bindings that move the selection or fold the tree.
|
// Navigation returns the bindings that move the selection or fold the tree.
|
||||||
func (k keyMap) Navigation() []key.Binding {
|
func (k keyMap) Navigation() []key.Binding {
|
||||||
move := key.NewBinding(key.WithKeys("up"), key.WithHelp("↑↓/jk", "move"))
|
move := key.NewBinding(key.WithKeys("up"), key.WithHelp("↑↓/jk", "move"))
|
||||||
fold := key.NewBinding(key.WithKeys("left"), key.WithHelp("←→/hl", "fold"))
|
fold := key.NewBinding(key.WithKeys("left"), key.WithHelp("←→/hl", "collapse/expand"))
|
||||||
all := key.NewBinding(key.WithKeys("H"), key.WithHelp("⇧←→/HL", "fold all"))
|
all := key.NewBinding(key.WithKeys("H"), key.WithHelp("⇧←→/HL", "all"))
|
||||||
return []key.Binding{move, fold, all, k.Toggle}
|
return []key.Binding{move, fold, all, k.Toggle}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Tools returns the bindings that act on the whole list: problems only,
|
||||||
|
// filter and fetch, most useful first, since the footer drops them from the
|
||||||
|
// end. It shows them after the navigation keys.
|
||||||
|
func (k keyMap) Tools() []key.Binding { return []key.Binding{k.Issues, k.Filter, k.Fetch} }
|
||||||
|
|
||||||
// Submodule returns the bindings that act on the selected submodule. They
|
// Submodule returns the bindings that act on the selected submodule. They
|
||||||
// are shown in the detail pane, or in the footer if the pane can't show them.
|
// are shown in the detail pane, or in the footer if the pane can't show them.
|
||||||
func (k keyMap) Submodule() []key.Binding { return []key.Binding{k.Copy, k.Switch, k.Restore} }
|
func (k keyMap) Submodule() []key.Binding {
|
||||||
|
return []key.Binding{k.Copy, k.Update, k.Switch, k.Restore, k.HardRestore}
|
||||||
|
}
|
||||||
|
|
||||||
// Global returns the bindings that don't depend on the selection.
|
// Global returns the bindings that don't depend on the selection.
|
||||||
func (k keyMap) Global() []key.Binding { return []key.Binding{k.Help, k.Quit} }
|
func (k keyMap) Global() []key.Binding { return []key.Binding{k.Help, k.Quit} }
|
||||||
@@ -78,17 +90,31 @@ func (k keyMap) Highlight() []key.Binding {
|
|||||||
return []key.Binding{k.QuitUpdate}
|
return []key.Binding{k.QuitUpdate}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (k keyMap) ShortHelp() []key.Binding {
|
// forSelection returns the keys as they read for the selection: on the
|
||||||
return append(append(k.Navigation(), k.Submodule()...), k.Global()...)
|
// superproject, u fast-forwards its branch (D29).
|
||||||
|
func (k keyMap) forSelection(root bool) keyMap {
|
||||||
|
if root {
|
||||||
|
k.Update.SetHelp("u", "pull (fast-forward)")
|
||||||
|
}
|
||||||
|
return k
|
||||||
}
|
}
|
||||||
|
|
||||||
func (k keyMap) FullHelp() [][]key.Binding {
|
// HelpGroups returns the columns of the full help, each with a title.
|
||||||
return [][]key.Binding{
|
func (k keyMap) HelpGroups() []footer.Group {
|
||||||
{k.Up, k.Down, k.Left, k.Right, k.Toggle},
|
help := key.NewBinding(key.WithKeys("?"), key.WithHelp("?", "what words mean"))
|
||||||
{k.CollapseAll, k.ExpandAll},
|
esc := key.NewBinding(key.WithKeys("esc"), key.WithHelp("esc", "close"))
|
||||||
{k.Home, k.End, k.PageUp, k.PageDown},
|
return []footer.Group{
|
||||||
{k.Copy, k.Switch, k.Restore},
|
{Title: "Move", Keys: []key.Binding{k.Up, k.Down, k.Left, k.Right, k.Toggle, k.CollapseAll, k.ExpandAll}},
|
||||||
{k.Fetch, k.Reload, k.Filter, k.Issues},
|
{Title: "Jump", Keys: []key.Binding{k.Home, k.End, k.PageUp, k.PageDown}},
|
||||||
append([]key.Binding{k.Help, k.Quit}, k.fullUpdate()...),
|
{Title: "Selected submodule", Keys: k.Submodule()},
|
||||||
|
{Title: "All submodules", Keys: []key.Binding{k.Fetch, k.Reload, k.Filter, k.Issues}},
|
||||||
|
{Title: "App", Keys: append([]key.Binding{help, esc, k.Quit}, k.fullUpdate()...)},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// LegendKeys returns the column next to the legend, the help's last page;
|
||||||
|
// ? closes it too, but esc is the key to tell.
|
||||||
|
func (k keyMap) LegendKeys() footer.Group {
|
||||||
|
esc := key.NewBinding(key.WithKeys("esc"), key.WithHelp("esc", "close"))
|
||||||
|
return footer.Group{Title: "App", Keys: append([]key.Binding{esc, k.Quit}, k.fullUpdate()...)}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
package tui
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestUpdateReadsPullOnTheSuperproject(t *testing.T) {
|
||||||
|
k := defaultKeys()
|
||||||
|
if got := k.forSelection(true).Update.Help().Desc; got != "pull (fast-forward)" {
|
||||||
|
t.Errorf("superproject: u reads %q", got)
|
||||||
|
}
|
||||||
|
if got := k.forSelection(false).Update.Help().Desc; got != "update pin" {
|
||||||
|
t.Errorf("submodule: u reads %q", got)
|
||||||
|
}
|
||||||
|
if got := k.Update.Help().Desc; got != "update pin" {
|
||||||
|
t.Errorf("forSelection changed the key map: u reads %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
package tui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
|
|
||||||
|
"submarine/internal/submodule"
|
||||||
|
)
|
||||||
|
|
||||||
|
// startPinUpdate moves the pin of the selected submodule to the tip of its
|
||||||
|
// ref, if the pin is only behind it: a switch to that branch, with the tip
|
||||||
|
// the row shows. Nothing is fetched. For the superproject the pin is HEAD
|
||||||
|
// and the ref its upstream: a fast-forward of its branch.
|
||||||
|
func (m Model) startPinUpdate() (tea.Model, tea.Cmd) {
|
||||||
|
s := m.tree.Selected()
|
||||||
|
if s == nil {
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
_, ok := m.provider.(submodule.Switcher)
|
||||||
|
var flash string
|
||||||
|
switch {
|
||||||
|
case !ok:
|
||||||
|
flash = "this data source can't update pins"
|
||||||
|
case m.restore != nil:
|
||||||
|
flash = "wait for the restore of " + m.restore.name + " to finish"
|
||||||
|
default:
|
||||||
|
flash = pinUpdateBlocker(s)
|
||||||
|
}
|
||||||
|
if flash != "" {
|
||||||
|
cmd := m.footer.Flash(flash)
|
||||||
|
m.layout()
|
||||||
|
return m, cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
st := s.Status
|
||||||
|
m.restore = &restoreState{path: s.Path, name: restoreName(s), pinUpdate: true, phase: restoreChecking}
|
||||||
|
m.layout()
|
||||||
|
ctx, path, ref, sha := m.ctx, s.Path, st.Pin.Ref, st.Tip.SHA
|
||||||
|
sw := m.provider.(submodule.Switcher)
|
||||||
|
check := func() tea.Msg {
|
||||||
|
b, err := pinUpdateBranch(ctx, sw, path, ref, sha)
|
||||||
|
if err != nil {
|
||||||
|
return lossMsg{path: path, err: err}
|
||||||
|
}
|
||||||
|
loss, err := sw.SwitchLoss(ctx, path, *b)
|
||||||
|
return lossMsg{path, loss, err, b}
|
||||||
|
}
|
||||||
|
return m, tea.Batch(check, m.tick())
|
||||||
|
}
|
||||||
|
|
||||||
|
// pinUpdateBlocker says why the pin of s can't be updated, "" if it can.
|
||||||
|
func pinUpdateBlocker(s *submodule.Submodule) string {
|
||||||
|
st, name := s.Status, restoreName(s)
|
||||||
|
pin := "the pin of " + name
|
||||||
|
if s.IsRoot() {
|
||||||
|
pin = name // its pin is HEAD
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case st == nil:
|
||||||
|
return name + " is still loading"
|
||||||
|
case st.Presence != submodule.PresenceOK:
|
||||||
|
return name + " is not initialized; restore it first"
|
||||||
|
case st.Pin.Ref == "" || st.Tip == nil || st.Pin.Source == submodule.RefFromTag || st.Pin.Source == submodule.RefFromSHA:
|
||||||
|
if s.IsRoot() {
|
||||||
|
return name + " has no upstream branch"
|
||||||
|
}
|
||||||
|
return pin + " is not on a branch"
|
||||||
|
case st.Pin.Ahead > 0 && st.Pin.Behind > 0:
|
||||||
|
return pin + " has diverged from " + st.Pin.Ref + "; b switches the branch"
|
||||||
|
case st.Pin.Ahead > 0:
|
||||||
|
return pin + " is ahead of " + st.Pin.Ref
|
||||||
|
case st.Pin.Behind == 0:
|
||||||
|
return name + " is up to date with " + st.Pin.Ref
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// pinUpdateBranch finds the branch ref among the branches of path. Its tip
|
||||||
|
// must still be sha, the tip the row showed (abbreviated, see Commit).
|
||||||
|
func pinUpdateBranch(ctx context.Context, sw submodule.Switcher, path, ref, sha string) (*submodule.Branch, error) {
|
||||||
|
bs, err := sw.Branches(ctx, path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
for _, b := range bs {
|
||||||
|
if b.Name != ref {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !b.Remote {
|
||||||
|
return nil, fmt.Errorf("%s is a local branch; b switches to it", ref)
|
||||||
|
}
|
||||||
|
if !strings.HasPrefix(b.SHA, sha) {
|
||||||
|
return nil, fmt.Errorf("%s moved; check the new state", ref)
|
||||||
|
}
|
||||||
|
return &b, nil
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("%s not found", ref)
|
||||||
|
}
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
package tui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
|
|
||||||
|
"submarine/internal/submodule"
|
||||||
|
)
|
||||||
|
|
||||||
|
// newPinUpdateModel is the switch model with the first submodule selected
|
||||||
|
// and given st as its status.
|
||||||
|
func newPinUpdateModel(t *testing.T, loss submodule.Loss, st *submodule.Status) (tea.Model, *fakeSwitcher, string) {
|
||||||
|
t.Helper()
|
||||||
|
m, f := newSwitchModel(t, loss)
|
||||||
|
repo, err := f.Tree(context.Background())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
path := repo.Submodules[0].Path
|
||||||
|
m, _ = m.Update(statusLoadedMsg{path: path, status: st})
|
||||||
|
mm := m.(Model)
|
||||||
|
mm.tree.Select(path)
|
||||||
|
return mm, f, path
|
||||||
|
}
|
||||||
|
|
||||||
|
// behind is a pin two commits behind origin/feat, whose tip is bbbbbbb.
|
||||||
|
func behind() *submodule.Status {
|
||||||
|
return &submodule.Status{
|
||||||
|
Pin: submodule.PinInfo{Ref: "origin/feat", Behind: 2, Pushed: true, Source: submodule.RefFromGitmodules},
|
||||||
|
Tip: &submodule.Commit{SHA: "bbbb"}, // abbreviated, like the git provider's
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPinUpdateSwitchesToTheTipOfTheRef(t *testing.T) {
|
||||||
|
m, f, path := newPinUpdateModel(t, submodule.Loss{}, behind())
|
||||||
|
m, cmd := press(m, "u")
|
||||||
|
m = settle(m, cmd)
|
||||||
|
if len(f.switched) != 1 || f.switched[0] != path+" refs/remotes/origin/feat" {
|
||||||
|
t.Fatalf("switched %q", f.switched)
|
||||||
|
}
|
||||||
|
if v := m.View(); !strings.Contains(v, "updated "+path+" to feat") {
|
||||||
|
t.Errorf("no flash:\n%s", v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPinUpdateAsksWhenSomethingIsLost(t *testing.T) {
|
||||||
|
m, f, path := newPinUpdateModel(t, submodule.Loss{Files: 1, Modules: 1}, behind())
|
||||||
|
m, cmd := press(m, "u")
|
||||||
|
for _, msg := range run(cmd) {
|
||||||
|
if l, ok := msg.(lossMsg); ok {
|
||||||
|
m, _ = m.Update(l)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if v := m.View(); !strings.Contains(v, "Update "+path+" to origin/feat?") || !strings.Contains(v, "y update") {
|
||||||
|
t.Errorf("question not shown:\n%s", v)
|
||||||
|
}
|
||||||
|
m, cmd = press(m, "y")
|
||||||
|
feed(m, cmd)
|
||||||
|
if len(f.switched) != 1 {
|
||||||
|
t.Fatalf("switched %q", f.switched)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPinUpdateAbortsWhenTheRefMoved(t *testing.T) {
|
||||||
|
st := behind()
|
||||||
|
st.Tip.SHA = "ccccccc" // the row showed another tip than the branch has now
|
||||||
|
m, f, _ := newPinUpdateModel(t, submodule.Loss{}, st)
|
||||||
|
m, cmd := press(m, "u")
|
||||||
|
m = settle(m, cmd)
|
||||||
|
if len(f.switched) != 0 {
|
||||||
|
t.Fatalf("switched %q", f.switched)
|
||||||
|
}
|
||||||
|
if v := m.View(); !strings.Contains(v, "origin/feat moved") {
|
||||||
|
t.Errorf("no notice:\n%s", v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPinUpdateOnlyWhenThePinIsBehind(t *testing.T) {
|
||||||
|
for _, tc := range []struct {
|
||||||
|
name string
|
||||||
|
edit func(*submodule.Status)
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{"up to date", func(s *submodule.Status) { s.Pin.Behind = 0 }, "is up to date with origin/feat"},
|
||||||
|
{"ahead", func(s *submodule.Status) { s.Pin.Behind, s.Pin.Ahead = 0, 1 }, "is ahead of origin/feat"},
|
||||||
|
{"diverged", func(s *submodule.Status) { s.Pin.Ahead = 1 }, "has diverged from origin/feat"},
|
||||||
|
{"tag", func(s *submodule.Status) { s.Pin.Source = submodule.RefFromTag }, "is not on a branch"},
|
||||||
|
{"missing", func(s *submodule.Status) { s.Presence = submodule.NotInitialized }, "is not initialized"},
|
||||||
|
} {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
st := behind()
|
||||||
|
tc.edit(st)
|
||||||
|
m, f, _ := newPinUpdateModel(t, submodule.Loss{}, st)
|
||||||
|
m, _ = press(m, "u") // the command is only the flash's timer
|
||||||
|
if len(f.switched) != 0 || m.(Model).restore != nil {
|
||||||
|
t.Fatalf("updated anyway: %q", f.switched)
|
||||||
|
}
|
||||||
|
if v := m.View(); !strings.Contains(v, tc.want) {
|
||||||
|
t.Errorf("flash lacks %q:\n%s", tc.want, v)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPinUpdateOfTheSuperprojectFastForwardsItsBranch(t *testing.T) {
|
||||||
|
m, f := newSwitchModel(t, submodule.Loss{}) // the superproject is selected
|
||||||
|
st := behind()
|
||||||
|
st.Root = true
|
||||||
|
m, _ = m.Update(statusLoadedMsg{path: "", status: st})
|
||||||
|
m, cmd := press(m, "u")
|
||||||
|
m = settle(m, cmd)
|
||||||
|
if len(f.switched) != 1 || f.switched[0] != " refs/remotes/origin/feat" {
|
||||||
|
t.Fatalf("switched %q", f.switched)
|
||||||
|
}
|
||||||
|
if v := m.View(); !strings.Contains(v, "updated the superproject to feat") {
|
||||||
|
t.Errorf("no flash:\n%s", v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPinUpdateOfTheSuperprojectWithoutUpstreamSaysSo(t *testing.T) {
|
||||||
|
m, f := newSwitchModel(t, submodule.Loss{})
|
||||||
|
st := behind()
|
||||||
|
st.Root, st.Pin.Source, st.Tip = true, submodule.RefFromSHA, nil
|
||||||
|
m, _ = m.Update(statusLoadedMsg{path: "", status: st})
|
||||||
|
m, _ = press(m, "u")
|
||||||
|
if len(f.switched) != 0 || !strings.Contains(m.View(), "the superproject has no upstream branch") {
|
||||||
|
t.Errorf("switched %q, view:\n%s", f.switched, m.View())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPinUpdateRefusesALocalRef(t *testing.T) {
|
||||||
|
st := behind()
|
||||||
|
st.Pin.Ref, st.Tip.SHA = "main", "aaaa" // upstream is a local branch
|
||||||
|
m, f, _ := newPinUpdateModel(t, submodule.Loss{}, st)
|
||||||
|
m, cmd := press(m, "u")
|
||||||
|
m = settle(m, cmd)
|
||||||
|
if len(f.switched) != 0 || !strings.Contains(m.View(), "main is a local branch") {
|
||||||
|
t.Errorf("switched %q, view:\n%s", f.switched, m.View())
|
||||||
|
}
|
||||||
|
}
|
||||||
+56
-19
@@ -28,13 +28,15 @@ type restoreState struct {
|
|||||||
to submodule.RestoreTarget
|
to submodule.RestoreTarget
|
||||||
loss submodule.Loss
|
loss submodule.Loss
|
||||||
branch *submodule.Branch // set for a branch switch, which reuses this flow
|
branch *submodule.Branch // set for a branch switch, which reuses this flow
|
||||||
|
pinUpdate bool // a switch to the pin's ref (u); branch is set by the check
|
||||||
}
|
}
|
||||||
|
|
||||||
// lossMsg is the result of the check before a restore.
|
// lossMsg is the result of the check before a restore.
|
||||||
type lossMsg struct {
|
type lossMsg struct {
|
||||||
path string
|
path string
|
||||||
loss submodule.Loss
|
loss submodule.Loss
|
||||||
err error
|
err error
|
||||||
|
branch *submodule.Branch // the branch a pin update switches to
|
||||||
}
|
}
|
||||||
|
|
||||||
type restoreDoneMsg struct {
|
type restoreDoneMsg struct {
|
||||||
@@ -45,8 +47,9 @@ type restoreDoneMsg struct {
|
|||||||
|
|
||||||
// startRestore checks what restoring the selection would discard. If nothing
|
// startRestore checks what restoring the selection would discard. If nothing
|
||||||
// would be lost it restores at once, else it asks first. A submodule with a
|
// would be lost it restores at once, else it asks first. A submodule with a
|
||||||
// staged pin first asks which pin to restore to.
|
// staged pin first asks which pin to restore to; a hard restore doesn't: it
|
||||||
func (m Model) startRestore() (tea.Model, tea.Cmd) {
|
// goes to the committed pins, at every depth.
|
||||||
|
func (m Model) startRestore(hard bool) (tea.Model, tea.Cmd) {
|
||||||
s := m.tree.Selected()
|
s := m.tree.Selected()
|
||||||
_, ok := m.provider.(submodule.Restorer)
|
_, ok := m.provider.(submodule.Restorer)
|
||||||
var flash string
|
var flash string
|
||||||
@@ -65,6 +68,10 @@ func (m Model) startRestore() (tea.Model, tea.Cmd) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
m.restore = &restoreState{path: s.Path, name: restoreName(s)}
|
m.restore = &restoreState{path: s.Path, name: restoreName(s)}
|
||||||
|
if hard {
|
||||||
|
m.restore.to = submodule.RestoreHard
|
||||||
|
return m.checkRestore()
|
||||||
|
}
|
||||||
if !s.IsRoot() && s.Status != nil && s.Status.PinStaged {
|
if !s.IsRoot() && s.Status != nil && s.Status.PinStaged {
|
||||||
m.restore.phase = restoreChoosing
|
m.restore.phase = restoreChoosing
|
||||||
m.layout()
|
m.layout()
|
||||||
@@ -86,7 +93,7 @@ func (m Model) checkRestore() (tea.Model, tea.Cmd) {
|
|||||||
} else {
|
} else {
|
||||||
loss, err = m.provider.(submodule.Restorer).RestoreLoss(ctx, path, to)
|
loss, err = m.provider.(submodule.Restorer).RestoreLoss(ctx, path, to)
|
||||||
}
|
}
|
||||||
return lossMsg{path, loss, err}
|
return lossMsg{path: path, loss: loss, err: err}
|
||||||
}
|
}
|
||||||
return m, tea.Batch(check, m.tick())
|
return m, tea.Batch(check, m.tick())
|
||||||
}
|
}
|
||||||
@@ -115,11 +122,18 @@ func (m Model) lossChecked(msg lossMsg) (tea.Model, tea.Cmd) {
|
|||||||
return m, nil
|
return m, nil
|
||||||
}
|
}
|
||||||
if msg.err != nil {
|
if msg.err != nil {
|
||||||
|
verb := "check"
|
||||||
|
if m.restore.pinUpdate {
|
||||||
|
verb = "update"
|
||||||
|
}
|
||||||
m.restore = nil
|
m.restore = nil
|
||||||
m.notice = "can't check " + restoreName(m.byPath[msg.path]) + ": " + msg.err.Error()
|
m.notice = "can't " + verb + " " + restoreName(m.byPath[msg.path]) + ": " + msg.err.Error()
|
||||||
m.layout()
|
m.layout()
|
||||||
return m, nil
|
return m, nil
|
||||||
}
|
}
|
||||||
|
if msg.branch != nil {
|
||||||
|
m.restore.branch = msg.branch
|
||||||
|
}
|
||||||
m.restore.loss = msg.loss
|
m.restore.loss = msg.loss
|
||||||
if msg.loss.Empty() {
|
if msg.loss.Empty() {
|
||||||
return m.runRestore()
|
return m.runRestore()
|
||||||
@@ -165,15 +179,15 @@ func (m Model) restoreDone(msg restoreDoneMsg) (tea.Model, tea.Cmd) {
|
|||||||
var cmds []tea.Cmd
|
var cmds []tea.Cmd
|
||||||
switch {
|
switch {
|
||||||
case r != nil && r.branch != nil && msg.err != nil:
|
case r != nil && r.branch != nil && msg.err != nil:
|
||||||
m.notice = "switch of " + r.name + " to " + r.branch.Name + " failed: " + msg.err.Error()
|
m.notice = r.noun() + " of " + r.name + " to " + r.branch.Name + " failed: " + msg.err.Error()
|
||||||
case r != nil && r.branch != nil:
|
case r != nil && r.branch != nil:
|
||||||
to := r.branch.Name
|
to := r.branch.Name
|
||||||
if msg.branch != "" {
|
if msg.branch != "" {
|
||||||
to = msg.branch
|
to = msg.branch
|
||||||
} else if r.branch.Remote {
|
} else if r.branch.Remote {
|
||||||
to += " (detached: local " + r.branch.Local + " is elsewhere)"
|
to += " (detached: local " + r.branch.Local + " can't be fast-forwarded)"
|
||||||
}
|
}
|
||||||
cmds = append(cmds, m.footer.Flash("switched "+r.name+" to "+to))
|
cmds = append(cmds, m.footer.Flash(r.verbEd()+" "+r.name+" to "+to))
|
||||||
default:
|
default:
|
||||||
name := "the superproject"
|
name := "the superproject"
|
||||||
if r != nil {
|
if r != nil {
|
||||||
@@ -190,34 +204,57 @@ func (m Model) restoreDone(msg restoreDoneMsg) (tea.Model, tea.Cmd) {
|
|||||||
return m, tea.Batch(cmds...)
|
return m, tea.Batch(cmds...)
|
||||||
}
|
}
|
||||||
|
|
||||||
// toText is " to origin/feat" for a switch, " to the committed pin" if that was chosen; the staged pin is the
|
// toText is " to origin/feat" for a switch, " to the committed pin" if that
|
||||||
// usual target and needs no words.
|
// was chosen, " to the committed pins" for a hard restore; the staged pin is
|
||||||
|
// the usual target and needs no words.
|
||||||
func (r *restoreState) toText() string {
|
func (r *restoreState) toText() string {
|
||||||
if r.branch != nil {
|
switch {
|
||||||
|
case r.branch != nil:
|
||||||
return " to " + r.branch.Name
|
return " to " + r.branch.Name
|
||||||
}
|
case r.to == submodule.RestoreHard:
|
||||||
if r.to == submodule.RestoreCommitted {
|
return " to the committed pins"
|
||||||
|
case r.to == submodule.RestoreCommitted:
|
||||||
return " to the committed pin"
|
return " to the committed pin"
|
||||||
}
|
}
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
// verb and verbIng word the operation: a switch is shown like a restore, but
|
// verb, verbIng, verbEd and noun word the operation: a switch and a pin
|
||||||
// names its branch.
|
// update are shown like a restore, but name their branch.
|
||||||
func (r *restoreState) verb() string {
|
func (r *restoreState) verb() string {
|
||||||
if r.branch != nil {
|
switch {
|
||||||
|
case r.pinUpdate:
|
||||||
|
return "Update"
|
||||||
|
case r.branch != nil:
|
||||||
return "Switch"
|
return "Switch"
|
||||||
}
|
}
|
||||||
return "Restore"
|
return "Restore"
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *restoreState) verbIng() string {
|
func (r *restoreState) verbIng() string {
|
||||||
if r.branch != nil {
|
switch {
|
||||||
|
case r.pinUpdate:
|
||||||
|
return "updating"
|
||||||
|
case r.branch != nil:
|
||||||
return "switching"
|
return "switching"
|
||||||
}
|
}
|
||||||
return "restoring"
|
return "restoring"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (r *restoreState) verbEd() string {
|
||||||
|
if r.pinUpdate {
|
||||||
|
return "updated"
|
||||||
|
}
|
||||||
|
return "switched"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *restoreState) noun() string {
|
||||||
|
if r.pinUpdate {
|
||||||
|
return "update"
|
||||||
|
}
|
||||||
|
return "switch"
|
||||||
|
}
|
||||||
|
|
||||||
func restoreName(s *submodule.Submodule) string {
|
func restoreName(s *submodule.Submodule) string {
|
||||||
if s == nil || s.IsRoot() {
|
if s == nil || s.IsRoot() {
|
||||||
return "the superproject"
|
return "the superproject"
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package tui
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"os"
|
"os"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
tea "github.com/charmbracelet/bubbletea"
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
@@ -187,3 +188,54 @@ func TestRestoreOfAStagedPinAsksWhichPin(t *testing.T) {
|
|||||||
t.Fatalf("restored %q to %v, want %s to the committed pin", f.restored, f.to, staged.Path)
|
t.Fatalf("restored %q to %v, want %s to the committed pin", f.restored, f.to, staged.Path)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestHardRestoreAsksToDropStagedPins(t *testing.T) {
|
||||||
|
m, f := newRestoreModel(t, submodule.Loss{StagedPins: 2, Modules: 2})
|
||||||
|
mm := m.(Model)
|
||||||
|
var staged *submodule.Submodule
|
||||||
|
submodule.Walk(mm.repo.Submodules, func(s *submodule.Submodule) {
|
||||||
|
if staged == nil && s.Children == nil {
|
||||||
|
staged = s
|
||||||
|
}
|
||||||
|
})
|
||||||
|
staged.Status = &submodule.Status{PinStaged: true, Pin: submodule.PinInfo{Pushed: true}}
|
||||||
|
mm.tree.Select(staged.Path)
|
||||||
|
m = mm
|
||||||
|
|
||||||
|
// not which pin, but y/n: staged pins alone are a reason to ask
|
||||||
|
m, cmd := press(m, "X")
|
||||||
|
for _, msg := range run(cmd) {
|
||||||
|
if l, ok := msg.(lossMsg); ok {
|
||||||
|
m, _ = m.Update(l)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
r := m.(Model).restore
|
||||||
|
if r == nil || r.phase != restoreAsking {
|
||||||
|
t.Fatalf("want a question, got %+v", r)
|
||||||
|
}
|
||||||
|
if got := lossText(r.loss); got != "2 staged pins" {
|
||||||
|
t.Errorf("question lists %q, want the staged pins", got)
|
||||||
|
}
|
||||||
|
m, cmd = press(m, "y")
|
||||||
|
feed(m, cmd)
|
||||||
|
if len(f.to) != 1 || f.to[0] != submodule.RestoreHard || f.restored[0] != staged.Path {
|
||||||
|
t.Fatalf("restored %q to %v, want %s hard", f.restored, f.to, staged.Path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHardRestoreAsksWhenWorkIsLost(t *testing.T) {
|
||||||
|
m, _ := newRestoreModel(t, submodule.Loss{Files: 1, StagedPins: 1, Modules: 1})
|
||||||
|
m, cmd := press(m, "X")
|
||||||
|
for _, msg := range run(cmd) {
|
||||||
|
if l, ok := msg.(lossMsg); ok {
|
||||||
|
m, _ = m.Update(l)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
r := m.(Model).restore
|
||||||
|
if r == nil || r.phase != restoreAsking {
|
||||||
|
t.Fatalf("want a question, got %+v", r)
|
||||||
|
}
|
||||||
|
if got := lossText(r.loss); !strings.Contains(got, "1 staged pin") {
|
||||||
|
t.Errorf("question %q doesn't list the staged pin", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
package theme
|
||||||
|
|
||||||
|
import "submarine/internal/submodule"
|
||||||
|
|
||||||
|
// LegendRow is one word or symbol of the UI, rendered as it appears, and
|
||||||
|
// what it means.
|
||||||
|
type LegendRow struct{ Word, Meaning string }
|
||||||
|
|
||||||
|
// LegendGroup is a titled column of the legend.
|
||||||
|
type LegendGroup struct {
|
||||||
|
Title string
|
||||||
|
Rows []LegendRow
|
||||||
|
}
|
||||||
|
|
||||||
|
// issueMeaning explains an issue word in a few plain words.
|
||||||
|
func issueMeaning(k submodule.IssueKind) string {
|
||||||
|
switch k {
|
||||||
|
case submodule.IssueUnpushed:
|
||||||
|
return "pin is on no remote"
|
||||||
|
case submodule.IssueConflict:
|
||||||
|
return "merge conflict"
|
||||||
|
case submodule.IssueMissing:
|
||||||
|
return "not checked out"
|
||||||
|
case submodule.IssueOffPin:
|
||||||
|
return "checkout is not the pin"
|
||||||
|
case submodule.IssueChanged:
|
||||||
|
return "uncommitted files"
|
||||||
|
default:
|
||||||
|
return "pin not on target branch"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Legend explains the words and symbols of the tree and the detail pane, in
|
||||||
|
// their colors: what the full help's second page shows.
|
||||||
|
func (t Theme) Legend() []LegendGroup {
|
||||||
|
g := t.Glyphs
|
||||||
|
muted := Fg(t.Muted)
|
||||||
|
status := LegendGroup{Title: "Status"}
|
||||||
|
for _, k := range submodule.Kinds {
|
||||||
|
status.Rows = append(status.Rows, LegendRow{t.LevelStyle(k.Level(), Fg(t.Text)).Render(IssueWord(k)), issueMeaning(k)})
|
||||||
|
}
|
||||||
|
status.Rows = append(status.Rows,
|
||||||
|
LegendRow{Fg(t.Info).Render(Staged), "new pin, not committed"},
|
||||||
|
LegendRow{muted.Render(InSync), "nothing to do"},
|
||||||
|
)
|
||||||
|
symbols := LegendGroup{Title: "Symbols", Rows: []LegendRow{
|
||||||
|
{Fg(t.Info).Render(g.Staged), "pin staged in parent"},
|
||||||
|
{muted.Render(g.Ahead + "n " + g.Behind + "n"), "ahead / behind the ref"},
|
||||||
|
{muted.Render("+n"), "n more issues"},
|
||||||
|
{Fg(t.Warn).Render(g.Problems + "k"), "issues below, collapsed"},
|
||||||
|
{muted.Render("[n]"), "children, collapsed"},
|
||||||
|
}}
|
||||||
|
commits := LegendGroup{Title: "Commits", Rows: []LegendRow{
|
||||||
|
{muted.Render(RoleWord(submodule.RolePin, "")), "what the parent records"},
|
||||||
|
{Fg(t.Text).Bold(true).Render(RoleWord(submodule.RoleYou, "")), "the checkout"},
|
||||||
|
{muted.Render(RoleWord(submodule.RoleTip, "")), "newest on the ref"},
|
||||||
|
{muted.Render("main"), "tip of target branch"},
|
||||||
|
{muted.Render(RoleWord(submodule.RoleBase, "")), "where lines split"},
|
||||||
|
{muted.Render(RoleWord(submodule.RoleHead, "")), "superproject checkout"},
|
||||||
|
}}
|
||||||
|
return []LegendGroup{status, symbols, commits}
|
||||||
|
}
|
||||||
@@ -66,3 +66,45 @@ func TestQuitAndUpdate(t *testing.T) {
|
|||||||
t.Errorf("ctrl+c must quit without updating, got update to %q", m2.(Model).UpdateTo())
|
t.Errorf("ctrl+c must quit without updating, got update to %q", m2.(Model).UpdateTo())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestHintUntilTheFirstKey(t *testing.T) {
|
||||||
|
m := newUpdateModel("")
|
||||||
|
if !m.(Model).extraLine() || !strings.Contains(m.View(), "new here?") {
|
||||||
|
t.Fatal("no hint on start")
|
||||||
|
}
|
||||||
|
m, _ = press(m, "j")
|
||||||
|
if m.(Model).extraLine() || strings.Contains(m.View(), "new here?") {
|
||||||
|
t.Error("the hint must go away with the first key")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEscClosesTheHelp(t *testing.T) {
|
||||||
|
for _, presses := range []int{1, 2} { // the keys page, the legend
|
||||||
|
m := newUpdateModel("")
|
||||||
|
for range presses {
|
||||||
|
m, _ = press(m, "?")
|
||||||
|
}
|
||||||
|
if !m.(Model).footer.HelpOpen() {
|
||||||
|
t.Fatalf("%d × ?: no help", presses)
|
||||||
|
}
|
||||||
|
m, _ = pressKey(m, tea.KeyEsc)
|
||||||
|
if m.(Model).footer.HelpOpen() {
|
||||||
|
t.Errorf("%d × ?: esc must close the help", presses)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEscClosesTheHelpBeforeTheFilter(t *testing.T) {
|
||||||
|
m := newUpdateModel("")
|
||||||
|
m, _ = press(m, "/")
|
||||||
|
m, _ = pressKey(m, tea.KeyEnter) // keys back to the tree, the filter stays
|
||||||
|
m, _ = press(m, "?")
|
||||||
|
m, _ = pressKey(m, tea.KeyEsc)
|
||||||
|
if mm := m.(Model); mm.footer.HelpOpen() || !mm.filtering {
|
||||||
|
t.Errorf("first esc: help open %v, filtering %v; want the help closed, the filter kept", mm.footer.HelpOpen(), mm.filtering)
|
||||||
|
}
|
||||||
|
m, _ = pressKey(m, tea.KeyEsc)
|
||||||
|
if m.(Model).filtering {
|
||||||
|
t.Error("second esc must close the filter")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,240 @@
|
|||||||
|
# Usability plan (draft)
|
||||||
|
|
||||||
|
**Status:** items 1–8 and 11 are done ([D31](../docs/decisions.md),
|
||||||
|
[D32](../docs/decisions.md)); the rest is a proposal. Items that change
|
||||||
|
behavior need a decision in [decisions.md](../docs/decisions.md) and
|
||||||
|
updates to [ux-guide.md](../docs/ux-guide.md) /
|
||||||
|
[actions.md](../docs/actions.md) when they are taken up.
|
||||||
|
|
||||||
|
Done differently than proposed below: the labels are `x restore to pin` and
|
||||||
|
`X restore committed pins`; the tools read `! problems only • / filter • f
|
||||||
|
fetch` (most useful first, dropped from the end); `space toggle` stays, as
|
||||||
|
it now follows `←→/hl collapse/expand`.
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
The target user is a developer who doesn't know git submodules well. They
|
||||||
|
should understand the screen and act safely **without reading a manual**.
|
||||||
|
At any moment the screen should answer three questions:
|
||||||
|
|
||||||
|
1. **What is wrong?** What does this state mean, in plain words?
|
||||||
|
2. **What can I do about it?** Which key fixes it?
|
||||||
|
3. **What will this key do?** What happens to my work, and can I undo it?
|
||||||
|
|
||||||
|
Today the tool answers (1) partly (the issue lines in the detail pane) and
|
||||||
|
(3) well *after* a key is pressed (loss question, flash). It hardly answers
|
||||||
|
(2), and it answers (3) only by trying. Most items below move the answers
|
||||||
|
from "after pressing" to "before pressing".
|
||||||
|
|
||||||
|
## Findings this is based on
|
||||||
|
|
||||||
|
From a walkthrough on the `make-fixture.sh` superproject (2026-10-24):
|
||||||
|
|
||||||
|
- `x restore` and `X hard restore` don't say the target ("to the pin"), the
|
||||||
|
scope (the submodule and everything below it) or how they differ. "Hard"
|
||||||
|
suggests "discards my changes", but both do that. The real difference,
|
||||||
|
staged pins, can't be guessed.
|
||||||
|
- `X` drops a staged pin without asking (`restored libs/staged to the
|
||||||
|
committed pins`), one Shift away from `x`.
|
||||||
|
- `f` fetch, `/` filter and `!` problems only appear only in `?`. The detail
|
||||||
|
pane says `fetched never` but gives no hint how to fetch. `!` is the most
|
||||||
|
useful view for many submodules, and nothing points to it.
|
||||||
|
- The words in the tree (`off pin`, `off target`, `staged`, `●`, `·1`, `↓2`)
|
||||||
|
and on the spine (`pin`, `tip`, `you`, `base`) are explained nowhere in
|
||||||
|
the UI.
|
||||||
|
- `u update pin` on the superproject actually fast-forwards the current
|
||||||
|
branch; the label stays the same.
|
||||||
|
- The palette is static: `u` is offered when it can't do anything, and the
|
||||||
|
user finds out from a flash.
|
||||||
|
- Mismatched words: the footer says "fold", the help "collapse/expand";
|
||||||
|
`space toggle` doesn't say what it toggles.
|
||||||
|
- Good, and worth keeping: questions only when something would be lost, the
|
||||||
|
loss listed exactly, flashes that say why and suggest another key.
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Effort: **S** = hours, mostly labels and docs; **M** = a day or two, new
|
||||||
|
logic in existing components; **L** = several days, new UI or new provider
|
||||||
|
calls.
|
||||||
|
Impact: how much it helps the target user's main flows (*understand state*,
|
||||||
|
*fix a submodule*, *find problems*, *stay safe*).
|
||||||
|
|
||||||
|
| # | Item | Effort | Impact | Flow |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| 1 ✓ | Clear names for restore and hard restore | S | High | fix, safe |
|
||||||
|
| 2 ✓ | Ask before `X` drops staged pins | S | High | safe |
|
||||||
|
| 3 ✓ | Show `/`, `!`, `f` in the footer | S | High | find |
|
||||||
|
| 4 ✓ | Status legend in `?` | S–M | High | understand |
|
||||||
|
| 5 ✓ | Groups with titles in the full help | S | Medium | understand |
|
||||||
|
| 6 ✓ | Plain-word fixes for wording | S | Low | understand |
|
||||||
|
| 7 ✓ | `u` label for the superproject | S | Medium | fix |
|
||||||
|
| 8 ✓ | Fetch hint at `fetched never` / old fetches | S | Medium | understand |
|
||||||
|
| 9 | Next step for each issue in the detail pane | M | **Highest** | understand, fix |
|
||||||
|
| 10 | Palette that fits the selection | M | High | fix |
|
||||||
|
| 11 ✓ | First-start hint | S | Medium | understand |
|
||||||
|
| 12 | Undo hint after a restore, switch or update | M | Medium | safe |
|
||||||
|
| 13 | Action menu on `enter` | M–L | High | fix, understand |
|
||||||
|
| 14 | "What would happen" preview | L | Medium | safe |
|
||||||
|
|
||||||
|
Quick view by quadrant:
|
||||||
|
|
||||||
|
| | **Low effort** | **Medium effort** | **High effort** |
|
||||||
|
|---|---|---|---|
|
||||||
|
| **High impact** | 1, 2, 3, 4 | 9, 10, 13 | |
|
||||||
|
| **Medium impact** | 5, 7, 8, 11 | 12 | 14 |
|
||||||
|
| **Low impact** | 6 | | |
|
||||||
|
|
||||||
|
## Items
|
||||||
|
|
||||||
|
### Low effort
|
||||||
|
|
||||||
|
**1. Clear names for restore and hard restore.** *S · High*
|
||||||
|
The labels should give target and difference without the docs.
|
||||||
|
Proposal: `x restore to pin`, `X restore, unstage pins`. In the full help,
|
||||||
|
longer: `x restore to pin (with everything below)`, `X restore to committed
|
||||||
|
pins, drop staged ones`. The question lines already use "to the committed
|
||||||
|
pins"; keep the same words everywhere.
|
||||||
|
Alternative: rename the action to "reset". Rejected for now: `git reset`
|
||||||
|
moves branches, which restore never does (D23).
|
||||||
|
Docs: ux-guide key reference, actions.md "Keys and palette", README.
|
||||||
|
|
||||||
|
**2. Ask before `X` drops staged pins.** *S · High*
|
||||||
|
Amends D30 ("alone they don't make it ask"). A staged pin is a decision
|
||||||
|
the user made, and `X` sits next to `x`. If `X` would drop at least one
|
||||||
|
staged pin, ask: `Restore src/app to the committed pins? Unstages 2 pins.
|
||||||
|
y restore · n cancel`. The loss is already counted, so only the trigger
|
||||||
|
changes. D30's argument (the commit stays in the submodule) holds for an
|
||||||
|
expert, not for someone who doesn't know where to find it again.
|
||||||
|
|
||||||
|
**3. Show `/`, `!`, `f` in the footer.** *S · High*
|
||||||
|
Proposal: `/ filter • ! problems • f fetch` after the navigation keys,
|
||||||
|
before the global keys. The existing rule drops keys from the left group's
|
||||||
|
end when the footer is too narrow, so narrow terminals lose them first and
|
||||||
|
nothing else changes. While problems only is on, the footer could read
|
||||||
|
`! all` (the action the key does now). `r reload` stays in `?`.
|
||||||
|
|
||||||
|
**4. Status legend in `?`.** *S–M · High*
|
||||||
|
The full help gets a second block, "What the words mean", with one line per
|
||||||
|
status word and symbol, using the same colors as the tree:
|
||||||
|
`off pin the checkout is not the commit the parent records`, `staged`,
|
||||||
|
`●`, `↑n ↓n`, `·k`, and the spine roles (`pin`, `tip`, `you`, `base`,
|
||||||
|
target). Wording comes from state-model.md; the explanations the detail
|
||||||
|
pane already uses are a good source. Layout: below the key columns if there
|
||||||
|
is room, otherwise `?` pages between keys and legend (`? more`).
|
||||||
|
|
||||||
|
**5. Groups with titles in the full help.** *S · Medium*
|
||||||
|
The help columns are already grouped, but without titles. Add short titles
|
||||||
|
on top: `Move`, `Selected submodule`, `All repositories`, `App`. Then it is
|
||||||
|
clear that `x` acts on the selection and `f` on everything.
|
||||||
|
|
||||||
|
**6. Plain-word fixes.** *S · Low*
|
||||||
|
- Footer `fold` → `collapse/expand`; `⇧←→/HL fold all` → `H/L all`.
|
||||||
|
- `space toggle` → `space open/close`.
|
||||||
|
- `c copy path` → say which path (`copy path` is fine if it is the
|
||||||
|
relative path; check).
|
||||||
|
- Header `pin staged` vs tree `staged`: one word.
|
||||||
|
|
||||||
|
**7. `u` label for the superproject.** *S · Medium*
|
||||||
|
With the header selected the palette says `u pull (fast-forward)`, since
|
||||||
|
that is what it does (D29). Labels that change with the selection need
|
||||||
|
item 10's mechanism, but this one is simple on its own: one check for the
|
||||||
|
header.
|
||||||
|
|
||||||
|
**8. Fetch hint.** *S · Medium*
|
||||||
|
`fetched never` (and an age above a threshold, e.g. a day) gets a muted
|
||||||
|
`· f fetches`. Users who don't know that the arrows (`↓2`) are only as fresh
|
||||||
|
as the last fetch otherwise trust old numbers.
|
||||||
|
|
||||||
|
**11. First-start hint.** *S · Medium*
|
||||||
|
On start, the footer's flash line shows `? explains keys and states` until
|
||||||
|
the first key. No config file, no state: it is shown every start, and it is
|
||||||
|
just a muted line. Makes item 4 discoverable.
|
||||||
|
|
||||||
|
### Medium effort
|
||||||
|
|
||||||
|
**9. Next step for each issue in the detail pane.** *M · Highest*
|
||||||
|
Each issue line ends with what to do about it, as keys:
|
||||||
|
|
||||||
|
```
|
||||||
|
off pin checkout is not the pin x back to pin · b record a branch
|
||||||
|
changed 3 files changed commit outside, or x discard
|
||||||
|
missing not initialized x restore
|
||||||
|
off target pin is not on origin/main b switch branch
|
||||||
|
```
|
||||||
|
|
||||||
|
A pin that is only behind (`↓2`, no issue) gets the same kind of hint on
|
||||||
|
its `Ref` line: `u update pin`.
|
||||||
|
|
||||||
|
This is the single biggest step for the target user: they no longer need to
|
||||||
|
know what to do, only to read. Layering: which action resolves which issue
|
||||||
|
is meaning, not looks, so it belongs in `internal/submodule` (e.g. a
|
||||||
|
`Remedies()` on the issue, returning action kinds) and the words in
|
||||||
|
`theme`. It must agree with what the actions really allow (e.g. `u` only
|
||||||
|
when behind and not ahead, D29), which item 10 needs anyway.
|
||||||
|
|
||||||
|
**10. Palette that fits the selection.** *M · High*
|
||||||
|
Amends actions.md ("the palette is static"). The palette keeps the same keys
|
||||||
|
in the same places (muscle memory), but:
|
||||||
|
- keys that can't do anything for the selection are drawn muted (`u` when
|
||||||
|
up to date, `b` on a missing submodule); pressing them still flashes why;
|
||||||
|
- the key that item 9 recommends for the most severe issue is highlighted.
|
||||||
|
The checks for `u` already exist as flash reasons; they are cheap and need
|
||||||
|
only the status, so this is a matter of calling them for the palette too.
|
||||||
|
Restore stays always active (it is idempotent).
|
||||||
|
|
||||||
|
**12. Undo hint after a restore, switch or update.** *M · Medium*
|
||||||
|
The success flash names the old commit when a repository moved:
|
||||||
|
`restored src/app (was 1a2b3c4)`. A flash only lasts a moment, so the detail
|
||||||
|
pane could keep `previous 1a2b3c4` until the next reload. For a user who
|
||||||
|
doesn't know `git reflog`, this is the difference between "lost" and
|
||||||
|
"recoverable". No new provider call if the SHA is taken before the action.
|
||||||
|
|
||||||
|
**13. Action menu on `enter`.** *M–L · High*
|
||||||
|
`enter` on a row opens a small popup (reusing the branch popup's frame)
|
||||||
|
listing the actions for this submodule, each with one line of explanation
|
||||||
|
and its key; the recommended one first and selected. `enter` runs it, `esc`
|
||||||
|
closes. Users who know the keys never need it; users who don't need no
|
||||||
|
memory at all. `enter` is unused in the tree today.
|
||||||
|
|
||||||
|
### High effort
|
||||||
|
|
||||||
|
**14. "What would happen" preview.** *L · Medium*
|
||||||
|
In the action menu (13), the selected entry shows the effect before
|
||||||
|
running it: `moves the pin e510d9b → 906f119 (2 commits)`, `discards 3
|
||||||
|
changed files`. The loss check exists, but it is async git work, so it runs
|
||||||
|
when the entry is selected, with a spinner. Valuable, but the loss question
|
||||||
|
already covers the dangerous cases, so this comes last.
|
||||||
|
|
||||||
|
## Order
|
||||||
|
|
||||||
|
1. **Labels and safety** (1, 2, 3, 5, 6, 7): only labels, docs and one
|
||||||
|
question trigger. Fixes the worst risk (`X`) and the hidden keys at once.
|
||||||
|
2. **Explain the screen** (4, 8, 11): the legend and the hints that point
|
||||||
|
to it.
|
||||||
|
3. **Guide to the fix** (9, 10): issue → key, and a palette that agrees.
|
||||||
|
Needs a decision on where remedies live (layering).
|
||||||
|
4. **Menus and previews** (12, 13, 14).
|
||||||
|
|
||||||
|
## How to check
|
||||||
|
|
||||||
|
Give someone who hasn't used submarine the fixture (`make-fixture.sh`) and
|
||||||
|
these tasks, without the docs:
|
||||||
|
|
||||||
|
- Find every submodule that needs attention.
|
||||||
|
- Get `ui` up to date with its branch.
|
||||||
|
- Put `core` back where the superproject wants it.
|
||||||
|
- Find out whether the arrows are current.
|
||||||
|
- Explain what `staged` on `libs/staged` means.
|
||||||
|
|
||||||
|
Note keys tried, wrong actions and questions asked. Run it before phase 1
|
||||||
|
and after each phase.
|
||||||
|
|
||||||
|
## Open questions
|
||||||
|
|
||||||
|
- Item 1: are "restore to pin" / "restore, unstage pins" the right words, or
|
||||||
|
should `X` move under a different key that is not one Shift away?
|
||||||
|
- Item 2: ask for every dropped staged pin, or only for the selection's own?
|
||||||
|
- Item 9: are external actions (`commit outside submarine`) good hints, or
|
||||||
|
only keys?
|
||||||
|
- Item 13: does `enter` stay free for something else (e.g. opening a shell
|
||||||
|
in the submodule)?
|
||||||
Reference in New Issue
Block a user