5 Commits
Author SHA1 Message Date
simonfrankenandClaude Opus 5.5 fbd233f58d Update a pin that is behind with u; switch fast-forwards
`u` moves the pin of the selection to the tip of its ref when the pin is
only behind it: a switch to that branch with exactly the tip the row
shows, nothing fetched; if the ref moved meanwhile, nothing happens. On
the superproject it fast-forwards the branch to its upstream and restores
the submodules (git pull --ff-only from the last fetch).

Switch now fast-forwards a local branch that is only behind the chosen
remote branch, unless another worktree has it checked out, and keeps a
checked-out branch that tracks it under another name. A branch with
commits of its own is still never moved.

Stage pin is dropped: switching to the branch already stages it. The
detail pane's palette wraps instead of cutting off keys. Add D29 and F59.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 22:39:18 +02:00
simonfrankenandClaude Opus 5.5 99f1ba2bec Mention branch attach of restore in the README
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 19:18:12 +02:00
simonfrankenandClaude Opus 5.5 651b0c2e69 Restore puts submodules on a branch at their pin
A restored submodule whose pin is a branch tip ends on that branch instead
of a detached HEAD: the local branch tracking the ref, else the ref created
as a tracking branch, else another local branch, else another remote branch
at the pin. No branch is moved; in-sync submodules are included (D28).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 19:17:26 +02:00
simonfranken 2aeebfe3b1 updates screenshot for readme 2026-10-23 18:48:58 +02:00
simonfrankenandClaude Sonnet 5.5 4ac29c04d0 Offer "quit and update" in the footer, check for updates on every start
The update check ran at most once a day and only printed a line after the
TUI exited. Now the registry is asked on every start (in the background,
3 s timeout, no cache) and the result reaches the TUI as a message.

With a newer release, the footer shows `q quit and update to vX` right of
help and quit, on a highlighted background (Theme.Highlight). `q` quits
and `main` installs the release once the terminal is restored, sharing
the code of `submarine update`; plain quit moves to `Q`, ctrl+c never
updates.

Drop LatestCached and CheckInterval. Add D27 and F58.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-23 18:43:17 +02:00
27 changed files with 1348 additions and 186 deletions
+24 -9
View File
@@ -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
@@ -131,11 +134,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
@@ -153,11 +167,12 @@ files.
| `/` | Filter | | `/` | Filter |
| `!` | Problems only | | `!` | Problems only |
| `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: make the worktree exactly what git records (asks if work would be lost) |
| `f` / `r` | Fetch / reload | | `f` / `r` | Fetch / reload |
| `?` | Full help | | `?` | Full help |
| `q` | Quit | | `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
the branch popup, click a branch to switch to it, use the wheel to move, and the branch popup, click a branch to switch to it, use the wheel to move, and
@@ -165,8 +180,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
+28 -28
View File
@@ -6,7 +6,6 @@ import (
"flag" "flag"
"fmt" "fmt"
"os" "os"
"path/filepath"
"time" "time"
tea "github.com/charmbracelet/bubbletea" tea "github.com/charmbracelet/bubbletea"
@@ -71,42 +70,38 @@ func main() {
zone.NewGlobal() zone.NewGlobal()
defer zone.Close() defer zone.Close()
notice := checkForUpdate(ctx, *useMock) var opts []tui.Option
if !*useMock && update.Release(version) && os.Getenv("SUBMARINE_NO_UPDATE_CHECK") == "" {
opts = append(opts, tui.WithUpdateCheck(latestRelease))
}
p := tea.NewProgram(tui.New(ctx, provider), tea.WithAltScreen(), tea.WithMouseCellMotion()) p := tea.NewProgram(tui.New(ctx, provider, opts...), tea.WithAltScreen(), tea.WithMouseCellMotion())
if _, err := p.Run(); err != nil { final, err := p.Run()
if err != nil {
fmt.Fprintln(os.Stderr, err) fmt.Fprintln(os.Stderr, err)
os.Exit(1) os.Exit(1)
} }
select { // The user chose "quit and update": the terminal is restored by now.
case v := <-notice: if m, ok := final.(tui.Model); ok && m.UpdateTo() != "" {
if v != "" { fmt.Printf("updating to %s…\n", m.UpdateTo())
fmt.Fprintf(os.Stderr, "submarine %s is available (you have %s), run: submarine update\n", v, version) ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute)
defer cancel()
if code := install(ctx, update.New(), m.UpdateTo()); code != 0 {
os.Exit(code)
} }
default:
} }
} }
// checkForUpdate looks for a newer release in the background (at most once a // latestRelease returns the version of a newer release, or "" if there is
// day, cached) and delivers its version, or "" when there is none. Disabled // none or the registry can't be reached within a few seconds.
// for dev builds, --mock and SUBMARINE_NO_UPDATE_CHECK. func latestRelease(ctx context.Context) string {
func checkForUpdate(ctx context.Context, off bool) <-chan string { ctx, cancel := context.WithTimeout(ctx, 3*time.Second)
out := make(chan string, 1) defer cancel()
cache, err := os.UserCacheDir() v, err := update.New().Latest(ctx)
if off || err != nil || !update.Release(version) || os.Getenv("SUBMARINE_NO_UPDATE_CHECK") != "" { if err != nil || !update.Newer(version, v) {
out <- "" return ""
return out
} }
go func() { return v
ctx, cancel := context.WithTimeout(ctx, 3*time.Second)
defer cancel()
v, err := update.New().LatestCached(ctx, filepath.Join(cache, "submarine", "latest-version"))
if err != nil || !update.Newer(version, v) {
v = ""
}
out <- v
}()
return out
} }
func runUpdate(args []string) int { func runUpdate(args []string) int {
@@ -135,6 +130,11 @@ func runUpdate(args []string) int {
fmt.Fprintf(os.Stderr, "submarine: this is a %q build; use --force to replace it with %s\n", version, latest) fmt.Fprintf(os.Stderr, "submarine: this is a %q build; use --force to replace it with %s\n", version, latest)
return 1 return 1
} }
return install(ctx, c, latest)
}
// install replaces the running binary with release latest.
func install(ctx context.Context, c *update.Client, latest string) int {
exe, err := c.Install(ctx, latest) exe, err := c.Install(ctx, latest)
if err != nil { if err != nil {
fmt.Fprintln(os.Stderr, "submarine: update failed:", err) fmt.Fprintln(os.Stderr, "submarine: update failed:", err)
+5 -4
View File
@@ -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,11 @@ 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, `b` switches its
branch ([actions.md](actions.md)); stage pin is drafted there. 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.
+113 -61
View File
@@ -1,8 +1,9 @@
# Actions (draft) # Actions (draft)
**Status:** [Restore](#restore) (key `x`, [D23](decisions.md)) and **Status:** [Restore](#restore) (key `x`, [D23](decisions.md)),
[Switch branch](#switch-branch) (key `b`, [D24](decisions.md)) are implemented. [Stage pin](#stage-pin) is still a draft; its open [Switch branch](#switch-branch) (key `b`, [D24](decisions.md)) and
questions are [at the end](#open-questions). [Update pin](#update-pin) (key `u`, [D29](decisions.md)) are implemented.
Stage pin was dropped ([D29](decisions.md)).
## Principle ## Principle
@@ -13,12 +14,12 @@ 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` |
| [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
@@ -86,7 +87,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 +102,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
@@ -187,6 +206,14 @@ 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.
For a submodule, after `submodule sync`, `attachBranch` puts a detached HEAD
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` the parent's index gets the committed pin first (`update-index With `c` the parent's index gets the committed pin first (`update-index
--cacheinfo` in the parent). --cacheinfo` in the parent).
@@ -232,9 +259,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 +285,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 +293,80 @@ 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.
| 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 | Restore the selection | `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 | [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 +379,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 +413,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?
+8 -5
View File
@@ -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
@@ -221,10 +222,12 @@ No CI runner: releases are built and published from a developer machine.
`VERSION`, downloads the asset for the running platform, verifies the `VERSION`, downloads the asset for the running platform, verifies the
checksum and atomically replaces the real executable (symlinks resolved, checksum and atomically replaces the real executable (symlinks resolved,
so `sub` works too). `SUBMARINE_UPDATE_URL` overrides the registry. so `sub` works too). `SUBMARINE_UPDATE_URL` overrides the registry.
- After the TUI exits, a one-line notice is printed if a newer release - On every start the TUI looks for a newer release in the background (3 s
exists. The check runs in the background at startup, at most once a day timeout, no cache, no blocking). `main` passes `tui.WithUpdateCheck` and
(cached in the user cache dir) and is off for dev builds, the TUI gets the result as a message; the footer then offers `q quit and
`--mock` and `SUBMARINE_NO_UPDATE_CHECK=1`. update to vX`. That key quits, and `main` installs the release once the
terminal is restored (the same code as `submarine update`). The check is off
for dev builds, `--mock` and `SUBMARINE_NO_UPDATE_CHECK=1`.
## Testing ## Testing
+89 -3
View File
@@ -200,7 +200,8 @@ package so versions stay clean.
**Consequences.** Integrity is a sha256 from the same host: it catches **Consequences.** Integrity is a sha256 from the same host: it catches
corrupt or partial downloads, not a compromised registry (no signatures). corrupt or partial downloads, not a compromised registry (no signatures).
The update check is a deliberate, outside-the-provider network use: only The update check is a deliberate, outside-the-provider network use: only
on `update` and a once-a-day background check that can be disabled. on `update` and a background check at startup that can be disabled (it
was once a day; see D27).
## Copy path with `c` via OSC 52 ## Copy path with `c` via OSC 52
@@ -326,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
@@ -360,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).
@@ -481,3 +483,87 @@ and the name is what people know. Long names are cut at 16 columns, so the
label column cannot grow without bound. In a label every role word has its label column cannot grow without bound. In a label every role word has its
own style, and `you` is bold in the default color: it was lost between the own style, and `you` is bold in the default color: it was lost between the
muted words, and it is the one the user looks for. muted words, and it is the one the user looks for.
## D27 — Update check on every start, offered as "quit and update"
**Decision.** The registry is asked on every start, in the background, with
no cache. A newer release shows up in the footer as `q quit and update to
vX`, right of help and quit, on a highlighted background (black on cyan).
While it is shown, `q` quits and updates, and plain quit is `Q`. `ctrl+c`
always quits without updating. The update runs after the TUI has exited.
**Rationale.** A once-a-day check with a notice printed after exit was
easy to miss. The hint is only useful where the user is looking, and the
key they use to leave is the natural place. Running the install after the
TUI exits keeps the terminal clean and reuses `submarine update`'s code.
**Consequences.** Every start makes one small request to the registry (3 s
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
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.
## 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.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 420 KiB

After

Width:  |  Height:  |  Size: 143 KiB

+15 -2
View File
@@ -145,7 +145,8 @@ 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).
- **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
@@ -155,7 +156,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
@@ -164,6 +171,12 @@ submarine [dir] # the repository containing dir (default: current directory)
submarine --mock # built-in mock data submarine --mock # built-in mock data
``` ```
- **F58** On every start the tool looks for a newer release in the
background (never blocking, silent on failure; off for dev builds,
`--mock` and `SUBMARINE_NO_UPDATE_CHECK=1`). If there is one, the footer
shows `q quit and update to vX` right of help and quit, highlighted; `q`
then quits and installs the release, and plain quit is `Q`.
## Non-functional requirements ## Non-functional requirements
- **N1** Startup must not block on the network. The tool shows the local - **N1** Startup must not block on the network. The tool shows the local
+23 -6
View File
@@ -42,10 +42,14 @@
Keys come in three groups (`keyMap.Navigation`, `.Submodule`, `.Global`): Keys come in three groups (`keyMap.Navigation`, `.Submodule`, `.Global`):
- **Navigation** (move, fold, toggle): left in the footer. - **Navigation** (move, fold, toggle): left in the footer.
- **Global** (`?`, `q`): right-aligned in the footer, always kept. - **Global** (`?`, `q`): right-aligned in the footer, always kept. If a newer
- **Submodule** (act on the selected one, e.g. `c`): a palette on the last release exists, `q quit and update to vX` follows them as the right-most
line of the detail pane. It is left out if the pane is shorter than 12 item, on a highlighted background (`Theme.Highlight`: black on Primary), and
lines. Whenever the palette isn't shown (pane hidden or too short), these plain quit is `Q` (see D27).
- **Submodule** (act on the selected one, e.g. `c`): a palette at the
bottom of the detail pane, wrapped onto a second line if the pane is too
narrow for all keys (none is cut off). It is left out if the pane is
shorter than 12 lines. Whenever the palette isn't shown (pane hidden or too short), these
keys move into the footer after the navigation keys. keys move into the footer after the navigation keys.
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
@@ -250,6 +254,7 @@ colors in components.
| Bar | adaptive 254 (light) / 235 (dark) | header background | | Bar | adaptive 254 (light) / 235 (dark) | header background |
| Selected | adaptive 253 (light) / 237 (dark) | selected row background | | Selected | adaptive 253 (light) / 237 (dark) | selected row background |
| Backdrop | adaptive 252 (light) / 238 (dark) | the screen behind a popup | | Backdrop | adaptive 252 (light) / 238 (dark) | the screen behind a popup |
| Highlight | ANSI `0` black, bold, on Primary | the `quit and update` hint in the footer |
- Background shades have no ANSI-16 equivalent, so they use `AdaptiveColor`. - Background shades have no ANSI-16 equivalent, so they use `AdaptiveColor`.
The background is detected once in `main` before Bubble Tea starts. The background is detected once in `main` before Bubble Tea starts.
@@ -283,7 +288,7 @@ colors in components.
| `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 | | `?` | Toggle full help |
| `q`, `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) |
| `c` | Copy the absolute directory of the selected submodule to the clipboard (OSC 52, so it also works over SSH; needs terminal support, in tmux `set -g set-clipboard on`). The footer confirms with `copied <path>`. Without a local provider (mock) the path relative to the superproject is copied | | `c` | Copy the absolute directory of the selected submodule to the clipboard (OSC 52, so it also works over SSH; needs terminal support, in tmux `set -g set-clipboard on`). The footer confirms with `copied <path>`. Without a local provider (mock) the path relative to the superproject is copied |
@@ -389,6 +394,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
@@ -406,6 +421,7 @@ 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) | Submodule | Palette, Help |
| `b` | Switch branch (popup) | Submodule | Palette, Help | | `b` | Switch branch (popup) | Submodule | Palette, Help |
| `x` | Restore | Submodule | Palette, Help | | `x` | Restore | Submodule | Palette, Help |
| `f` | Fetch all repositories | Global | Help | | `f` | Fetch all repositories | Global | Help |
@@ -413,7 +429,8 @@ where the palette can't be shown, see [Key bar and palette](#key-bar-and-palette
| `/` | Filter | Global | Help | | `/` | Filter | Global | Help |
| `!` | Problems only (toggle) | Global | Help | | `!` | Problems only (toggle) | Global | Help |
| `?` | Toggle full help | Global | Bar, Help | | `?` | Toggle full help | Global | Bar, Help |
| `q`, `ctrl+c` | Quit | 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 |
### Mouse ### Mouse
+18 -8
View File
@@ -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,
+154 -4
View File
@@ -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"
@@ -45,7 +47,7 @@ func (p *Provider) RestoreLoss(ctx context.Context, path string, to submodule.Re
// submodule is cloned and missing commits are fetched; git never prompts. // 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)
} }
p.mu.RLock() p.mu.RLock()
in, ok := p.mods[path] in, ok := p.mods[path]
@@ -67,7 +69,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)
} }
// restoreTarget returns the directory and the commit path is restored to: // restoreTarget returns the directory and the commit path is restored to:
@@ -123,7 +125,9 @@ func pinOf(ctx context.Context, in info, to submodule.RestoreTarget) (string, er
// 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, its staged pins are kept and are the submodules' targets.
func (p *Provider) restoreRepo(ctx context.Context, dir, target string) error { // 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) 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,6 +142,7 @@ 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)
} }
prev, _, _ := try(ctx, dir, "symbolic-ref", "--short", "-q", "HEAD") // for the ref, see attachBranch
var keep []gitlink var keep []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 {
@@ -161,6 +166,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 +188,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 +205,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); err != nil {
errs[i] = err errs[i] = err
} }
}() }()
@@ -194,6 +215,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...)
+127
View File
@@ -332,3 +332,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)
}
}
+52 -9
View File
@@ -90,8 +90,11 @@ func (p *Provider) SwitchLoss(ctx context.Context, path string, b submodule.Bran
// 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); 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
} }
+111 -2
View File
@@ -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")
+43 -2
View File
@@ -61,11 +61,28 @@ type Model struct {
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
checkUpdate func(context.Context) string // looks for a newer release, nil if off
update string // the newer release found, "" if none
updateTo string // the release to quit and update to, "" if not chosen
width, height int width, height int
showDetail bool showDetail bool
} }
func New(ctx context.Context, p submodule.Provider) Model { // Option configures New.
type Option func(*Model)
// WithUpdateCheck looks for a newer release at startup, in the background.
// check returns its version, or "" if there is none. With one, the footer
// offers to quit and update.
func WithUpdateCheck(check func(context.Context) string) Option {
return func(m *Model) { m.checkUpdate = check }
}
// UpdateTo returns the release the user chose to quit and update to, or "".
func (m Model) UpdateTo() string { return m.updateTo }
func New(ctx context.Context, p submodule.Provider, opts ...Option) Model {
t := theme.Default() t := theme.Default()
k := defaultKeys() k := defaultKeys()
sp := spinner.New(spinner.WithSpinner(spinner.MiniDot)) sp := spinner.New(spinner.WithSpinner(spinner.MiniDot))
@@ -90,12 +107,22 @@ func New(ctx context.Context, p submodule.Provider) Model {
} }
m.fetcher, _ = p.(submodule.Fetcher) m.fetcher, _ = p.(submodule.Fetcher)
m.watcher, _ = p.(submodule.Watcher) m.watcher, _ = p.(submodule.Watcher)
for _, o := range opts {
o(&m)
}
return m return m
} }
func (m Model) Init() tea.Cmd { func (m Model) Init() tea.Cmd {
m.loads.start(treeKey) m.loads.start(treeKey)
return tea.Batch(m.loadTree(), m.spinner.Tick) return tea.Batch(m.loadTree(), m.spinner.Tick, m.lookForUpdate())
}
func (m Model) lookForUpdate() tea.Cmd {
if m.checkUpdate == nil {
return nil
}
return func() tea.Msg { return updateMsg{m.checkUpdate(m.ctx)} }
} }
func (m Model) loadTree() tea.Cmd { func (m Model) loadTree() tea.Cmd {
@@ -187,6 +214,15 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.layout() m.layout()
return m, nil return m, nil
case updateMsg:
if msg.version != "" {
m.update = msg.version
m.keys.SetUpdate(msg.version)
m.footer.SetKeys(m.keys)
m.layout()
}
return m, nil
case treeLoadedMsg: case treeLoadedMsg:
return m.treeLoaded(msg) return m.treeLoaded(msg)
@@ -361,6 +397,9 @@ func (m Model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
m.closeFilter() m.closeFilter()
case key.Matches(msg, k.Quit): case key.Matches(msg, k.Quit):
return m, tea.Quit return m, tea.Quit
case key.Matches(msg, k.QuitUpdate):
m.updateTo = m.update
return m, tea.Quit
case key.Matches(msg, k.Help): case key.Matches(msg, k.Help):
m.footer.ToggleHelp() m.footer.ToggleHelp()
m.layout() m.layout()
@@ -412,6 +451,8 @@ func (m Model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
return m.startRestore() return m.startRestore()
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()
+27 -6
View File
@@ -55,20 +55,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 {
+41
View File
@@ -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")),
}
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"} {
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)
}
}
+13 -1
View File
@@ -19,13 +19,15 @@ 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 (and
// Submodule, if requested) on the left and Global on the right; full help // Submodule, if requested) on the left, Global and then Highlight (on a
// highlighted background) on the right; full help
// shows FullHelp. // shows FullHelp.
type Keys interface { type Keys interface {
help.KeyMap help.KeyMap
Navigation() []key.Binding Navigation() []key.Binding
Submodule() []key.Binding Submodule() []key.Binding
Global() []key.Binding Global() []key.Binding
Highlight() []key.Binding
} }
type Model struct { type Model struct {
@@ -42,6 +44,9 @@ func New(t theme.Theme, keys Keys) Model {
return Model{theme: t, help: theme.NewHelp(t), keys: keys} return Model{theme: t, help: theme.NewHelp(t), keys: keys}
} }
// SetKeys replaces the key groups, e.g. if bindings changed.
func (m *Model) SetKeys(k Keys) { m.keys = k }
// SetActions shows the submodule bindings in the short bar. Set it if the // SetActions shows the submodule bindings in the short bar. Set it if the
// detail pane doesn't show them. // detail pane doesn't show them.
func (m *Model) SetActions(on bool) { m.actions = on } func (m *Model) SetActions(on bool) { m.actions = on }
@@ -85,6 +90,13 @@ func (m Model) shortBar(w int) string {
h := m.help h := m.help
h.Width = 0 h.Width = 0
right := h.ShortHelpView(m.keys.Global()) right := h.ShortHelpView(m.keys.Global())
if hl := m.keys.Highlight(); len(hl) > 0 {
var parts []string
for _, b := range hl {
parts = append(parts, b.Help().Key+" "+b.Help().Desc)
}
right += " " + m.theme.Highlight().Render(" "+strings.Join(parts, " ")+" ")
}
left := m.keys.Navigation() left := m.keys.Navigation()
if m.actions { if m.actions {
left = append(left[:len(left):len(left)], m.keys.Submodule()...) left = append(left[:len(left):len(left)], m.keys.Submodule()...)
+54
View File
@@ -0,0 +1,54 @@
package footer
import (
"strings"
"testing"
"github.com/charmbracelet/bubbles/key"
"github.com/charmbracelet/lipgloss"
"github.com/charmbracelet/x/ansi"
"submarine/internal/tui/theme"
)
type testKeys struct{ highlight []key.Binding }
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 {
return []key.Binding{bind("j", "move"), bind("h", "fold")}
}
func (testKeys) Submodule() []key.Binding { return nil }
func (testKeys) Global() []key.Binding { return []key.Binding{bind("?", "help"), bind("Q", "quit")} }
func (k testKeys) Highlight() []key.Binding { return k.highlight }
func bar(w int, hl ...key.Binding) string {
m := New(theme.Default(), testKeys{hl})
m.SetWidth(w)
return ansi.Strip(m.View())
}
func TestUpdateIsRightmost(t *testing.T) {
got := strings.TrimRight(bar(80, bind("q", "quit and update to v1.2.3")), " ")
if !strings.HasSuffix(got, "? help • Q quit q quit and update to v1.2.3") {
t.Errorf("order must be help, quit, quit and update:\n%q", got)
}
if w := lipgloss.Width(bar(80, bind("q", "quit and update to v1.2.3"))); w != 80 {
t.Errorf("bar is %d wide, want 80", w)
}
}
func TestNoUpdate(t *testing.T) {
if got := bar(80); strings.Contains(got, "update") {
t.Errorf("unexpected update hint:\n%q", got)
}
}
func TestNavigationGivesWayToUpdate(t *testing.T) {
got := bar(50, bind("q", "quit and update to v1.2.3"))
if !strings.Contains(got, "q quit and update to v1.2.3") || !strings.Contains(got, "Q quit") {
t.Errorf("the right group must be kept:\n%q", got)
}
}
+36 -6
View File
@@ -1,14 +1,16 @@
package tui package tui
import "github.com/charmbracelet/bubbles/key" import (
"github.com/charmbracelet/bubbles/key"
)
type keyMap struct { type keyMap struct {
Up, Down, Left, Right, Toggle key.Binding Up, Down, Left, Right, Toggle key.Binding
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
Help, Quit key.Binding Help, Quit, QuitUpdate key.Binding
} }
func defaultKeys() keyMap { func defaultKeys() keyMap {
@@ -30,12 +32,21 @@ func defaultKeys() keyMap {
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")),
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")),
QuitUpdate: key.NewBinding(key.WithKeys("q"), key.WithHelp("q", "quit and update"), key.WithDisabled()),
} }
} }
// SetUpdate announces the newer release v: q quits and updates to it, and the
// plain quit moves to Q (ctrl+c always quits without updating).
func (k *keyMap) SetUpdate(v string) {
k.Quit = key.NewBinding(key.WithKeys("Q", "ctrl+c"), key.WithHelp("Q", "quit"))
k.QuitUpdate = key.NewBinding(key.WithKeys("q"), key.WithHelp("q", "quit and update to "+v))
}
// 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"))
@@ -46,11 +57,30 @@ func (k keyMap) Navigation() []key.Binding {
// 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}
}
// 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} }
// fullUpdate is Highlight with a short description, for the full help.
func (k keyMap) fullUpdate() []key.Binding {
if !k.QuitUpdate.Enabled() {
return nil
}
return []key.Binding{key.NewBinding(key.WithKeys("q"), key.WithHelp("q", "quit and update"))}
}
// Highlight returns the bindings that are shown after Global on a highlighted
// background: quit and update, if there is an update.
func (k keyMap) Highlight() []key.Binding {
if !k.QuitUpdate.Enabled() {
return nil
}
return []key.Binding{k.QuitUpdate}
}
func (k keyMap) ShortHelp() []key.Binding { func (k keyMap) ShortHelp() []key.Binding {
return append(append(k.Navigation(), k.Submodule()...), k.Global()...) return append(append(k.Navigation(), k.Submodule()...), k.Global()...)
} }
@@ -60,8 +90,8 @@ func (k keyMap) FullHelp() [][]key.Binding {
{k.Up, k.Down, k.Left, k.Right, k.Toggle}, {k.Up, k.Down, k.Left, k.Right, k.Toggle},
{k.CollapseAll, k.ExpandAll}, {k.CollapseAll, k.ExpandAll},
{k.Home, k.End, k.PageUp, k.PageDown}, {k.Home, k.End, k.PageUp, k.PageDown},
{k.Copy, k.Switch, k.Restore}, {k.Copy, k.Update, k.Switch, k.Restore},
{k.Fetch, k.Reload, k.Filter, k.Issues}, {k.Fetch, k.Reload, k.Filter, k.Issues},
{k.Help, k.Quit}, append([]key.Binding{k.Help, k.Quit}, k.fullUpdate()...),
} }
} }
+4
View File
@@ -26,3 +26,7 @@ type changeMsg struct {
change submodule.Change change submodule.Change
closed bool // the watcher stopped closed bool // the watcher stopped
} }
// updateMsg carries the result of the startup update check; version is ""
// if there is no newer release.
type updateMsg struct{ version string }
+101
View File
@@ -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)
}
+143
View File
@@ -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())
}
}
+41 -12
View File
@@ -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 {
@@ -86,7 +88,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 +117,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 +174,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 {
@@ -202,22 +211,42 @@ func (r *restoreState) toText() string {
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"
+10 -1
View File
@@ -1,6 +1,9 @@
package theme package theme
import "github.com/charmbracelet/bubbles/help" import (
"github.com/charmbracelet/bubbles/help"
"github.com/charmbracelet/lipgloss"
)
// NewHelp returns a help renderer in the theme's colors. // NewHelp returns a help renderer in the theme's colors.
func NewHelp(t Theme) help.Model { func NewHelp(t Theme) help.Model {
@@ -13,3 +16,9 @@ func NewHelp(t Theme) help.Model {
h.Styles.FullSeparator = h.Styles.ShortSeparator h.Styles.FullSeparator = h.Styles.ShortSeparator
return h return h
} }
// Highlight is the style of a key hint that should catch the eye: dark text
// on the accent color, on the whole hint including its key.
func (t Theme) Highlight() lipgloss.Style {
return lipgloss.NewStyle().Foreground(lipgloss.Color("0")).Background(t.Primary).Bold(true)
}
+68
View File
@@ -0,0 +1,68 @@
package tui
import (
"context"
"strings"
"testing"
tea "github.com/charmbracelet/bubbletea"
"submarine/internal/submodule/mock"
)
func newUpdateModel(version string) tea.Model {
check := func(context.Context) string { return version }
var m tea.Model = New(context.Background(), mock.New(), WithUpdateCheck(check))
m, _ = m.Update(tea.WindowSizeMsg{Width: 120, Height: 30})
return m
}
func isQuit(cmd tea.Cmd) bool {
if cmd == nil {
return false
}
_, ok := cmd().(tea.QuitMsg)
return ok
}
func TestUpdateCheckDeliversMessage(t *testing.T) {
m := New(context.Background(), mock.New(), WithUpdateCheck(func(context.Context) string { return "v9.9.9" }))
if m.lookForUpdate() == nil {
t.Fatal("no update check was started")
}
if msg := m.lookForUpdate()(); msg != (updateMsg{"v9.9.9"}) {
t.Errorf("got %v", msg)
}
if New(context.Background(), mock.New()).lookForUpdate() != nil {
t.Error("an update check was started without the option")
}
}
func TestQuitWithoutUpdate(t *testing.T) {
m := newUpdateModel("")
m, _ = m.Update(updateMsg{""})
m, cmd := press(m, "q")
if !isQuit(cmd) || m.(Model).UpdateTo() != "" {
t.Errorf("q must quit without updating, got update to %q", m.(Model).UpdateTo())
}
}
func TestQuitAndUpdate(t *testing.T) {
m := newUpdateModel("v9.9.9")
m, _ = m.Update(updateMsg{"v9.9.9"})
if bar := m.(Model).footer.View(); !strings.Contains(bar, "q quit and update to v9.9.9") {
t.Errorf("the footer does not offer the update:\n%s", bar)
}
m2, cmd := press(m, "q")
if !isQuit(cmd) || m2.(Model).UpdateTo() != "v9.9.9" {
t.Errorf("q must quit and update, got update to %q", m2.(Model).UpdateTo())
}
m2, cmd = press(m, "Q")
if !isQuit(cmd) || m2.(Model).UpdateTo() != "" {
t.Errorf("Q must quit without updating, got update to %q", m2.(Model).UpdateTo())
}
m2, cmd = pressKey(m, tea.KeyCtrlC)
if !isQuit(cmd) || m2.(Model).UpdateTo() != "" {
t.Errorf("ctrl+c must quit without updating, got update to %q", m2.(Model).UpdateTo())
}
}
-17
View File
@@ -209,20 +209,3 @@ func (l *limitWriter) Write(p []byte) (int, error) {
} }
return l.w.Write(p) return l.w.Write(p)
} }
// CheckInterval is how often the passive check may hit the network.
const CheckInterval = 24 * time.Hour
// LatestCached is Latest with a file cache: within CheckInterval it returns
// the cached answer without any network access. A failed lookup is cached too,
// so an unreachable registry is not retried on every start.
func (c *Client) LatestCached(ctx context.Context, cacheFile string) (string, error) {
if fi, err := os.Stat(cacheFile); err == nil && time.Since(fi.ModTime()) < CheckInterval {
b, _ := os.ReadFile(cacheFile)
return strings.TrimSpace(string(b)), nil
}
v, err := c.Latest(ctx)
_ = os.MkdirAll(filepath.Dir(cacheFile), 0o755)
_ = os.WriteFile(cacheFile, []byte(v), 0o644) // v is "" on failure
return v, err
}