From fbd233f58d8aae5c137a79ca2434d4e4bd481b63 Mon Sep 17 00:00:00 2001 From: Simon Franken Date: Sat, 24 Oct 2026 22:39:13 +0200 Subject: [PATCH] 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 --- README.md | 25 +++-- docs/README.md | 9 +- docs/actions.md | 135 +++++++++++++----------- docs/architecture.md | 3 +- docs/decisions.md | 39 ++++++- docs/requirements.md | 8 +- docs/ux-guide.md | 18 +++- internal/submodule/git/switch.go | 59 +++++++++-- internal/submodule/git/switch_test.go | 113 +++++++++++++++++++- internal/tui/app.go | 2 + internal/tui/detail/detail.go | 33 ++++-- internal/tui/detail/palette_test.go | 41 ++++++++ internal/tui/keys.go | 9 +- internal/tui/pinupdate.go | 101 ++++++++++++++++++ internal/tui/pinupdate_test.go | 143 ++++++++++++++++++++++++++ internal/tui/restore.go | 53 +++++++--- 16 files changed, 686 insertions(+), 105 deletions(-) create mode 100644 internal/tui/detail/palette_test.go create mode 100644 internal/tui/pinupdate.go create mode 100644 internal/tui/pinupdate_test.go diff --git a/README.md b/README.md index 8b97e5f..94ba1a7 100644 --- a/README.md +++ b/README.md @@ -29,8 +29,9 @@ the questions you actually have: seconds. It never fetches on its own: the detail pane shows when a repository was last fetched, `f` fetches everything. - **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 - would be lost. + records (`x`), switch it to another branch (`b`), or move a pin that is + 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 asynchronously, so the UI never freezes. @@ -133,11 +134,22 @@ the local and remote branches, newest first; type to filter. `↑` `↓` 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 -branch. Picking `origin/feat` creates a local `feat` that tracks it; an -existing local branch of that name is never moved. Like restore, it asks +branch. Picking `origin/feat` creates a local `feat` that tracks it. Like restore, it asks first if work would be lost. The list is read from disk, so press `f` first 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 `f` fetches all repositories again, `r` reloads everything after you edited @@ -155,6 +167,7 @@ files. | `/` | Filter | | `!` | Problems only | | `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 | | `x` | Restore: make the worktree exactly what git records (asks if work would be lost) | | `f` / `r` | Fetch / reload | @@ -167,8 +180,8 @@ click outside to close it. ## Status -A prototype. Browsing is complete. Two write actions exist: restore (`x`) -and switch branch (`b`). More (stage pin) are being specified in +A prototype. Browsing is complete. Three write actions exist: restore (`x`), +switch branch (`b`) and update pin (`u`), see [docs/actions.md](docs/actions.md). ## Documentation diff --git a/docs/README.md b/docs/README.md index 78fb958..7f629ff 100644 --- a/docs/README.md +++ b/docs/README.md @@ -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 | | [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 | -| [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) | ## 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 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 -submodules with problems. `x` restores the selection and `b` switches its -branch ([actions.md](actions.md)); stage pin is drafted there. +submodules with problems. `x` restores the selection, `b` switches its +branch and `u` updates a pin that is behind ([actions.md](actions.md)). 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. diff --git a/docs/actions.md b/docs/actions.md index 5c7ee15..11b006b 100644 --- a/docs/actions.md +++ b/docs/actions.md @@ -1,8 +1,9 @@ # Actions (draft) -**Status:** [Restore](#restore) (key `x`, [D23](decisions.md)) and -[Switch branch](#switch-branch) (key `b`, [D24](decisions.md)) are implemented. [Stage pin](#stage-pin) is still a draft; its open -questions are [at the end](#open-questions). +**Status:** [Restore](#restore) (key `x`, [D23](decisions.md)), +[Switch branch](#switch-branch) (key `b`, [D24](decisions.md)) and +[Update pin](#update-pin) (key `u`, [D29](decisions.md)) are implemented. +Stage pin was dropped ([D29](decisions.md)). ## Principle @@ -13,12 +14,12 @@ a difference in one of two directions: | Action | Direction | Key | |---|---|---| | [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` | +| [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 -tip are done outside submarine. A bump is "check out the tip in the -submodule, then `s`" (or switch to the branch with `b`). +There is nothing else for now. Committing and pushing are done outside +submarine. Recording a commit made in a submodule is a switch to its branch +(`b`); a commit on no branch must get one first. ## Restore @@ -258,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`. 3. HEAD is put on the branch (the branch name is the local one): - a local branch: checked out; - - a remote branch `origin/feat`: the local branch `feat` is created, - tracking it; if `feat` exists at `T` it is checked out; if it exists - **elsewhere** it is not moved and HEAD stays detached at `T`. + - a remote branch `origin/feat`: the local branch is the one checked out + before if it tracks `origin/feat` (e.g. `dev`), else `feat`. It is + 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 restore, so its submodules follow the pins of the new commit (staged pins of @@ -279,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 is missing below, like restore. Lines above the footer: `checking …`, `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 restore or switch runs at a time. @@ -287,72 +293,80 @@ restore or switch runs at a time. `git.Provider.Switch` (`internal/submodule/git/switch.go`): verify the ref is still `T`; `update-index --cacheinfo` in the parent; `restoreRepo` (see -restore); then `switch` / `switch -c --track `. The restore leaves HEAD of the +restore); then `switch` / `switch -c --track `; a local branch +behind `T` is first moved with `merge-base --is-ancestor T`, a look +at `worktree list --porcelain` and `update-ref refs/heads/ T ` +(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 `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 -parent's index. It is the same as `git add ` in the parent, but only -when it is safe. +Moves the pin of a submodule to the tip of its ref, the branch the row shows +(`origin/main ↓2`). It is a [switch](#switch-branch) to that branch, with the +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 All of: -- the selected node is a submodule, not the superproject; -- it is `off pin` (`Checkout == CheckoutDiffers`): HEAD differs from the - pin in the parent's index; -- its worktree is **clean** (`Worktree == WorktreeClean`). Uncommitted - changes, untracked files and conflicts count as dirty, the same rule as - the `changed` issue. A moved nested pin counts as a change too, so nested - 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. +- it is initialized and its state is loaded; +- its ref is a remote branch (not a tag, a SHA or a local branch) and has a + tip; for the superproject: its branch has an upstream; +- the pin is **behind** the ref and not ahead (`Behind > 0`, `Ahead == 0`). + A diverged pin (`↑n↓m`) is left to `b`: moving it would drop the pin's + own commits from the record. ### Effect -- The parent's index records the submodule's HEAD as the gitlink of its - path. Nothing else changes: no worktree, no other index entry, no commit. -- The submodule then shows `staged` (or `●` next to an issue), and the - `off pin` issue is gone. -- If the checkout equals the pin in the parent's `HEAD`, staging removes - the staged pin instead (the index equals `HEAD` again). -- If the checked-out commit is not pushed, it is staged anyway. The row - then shows `unpushed` (Danger), which is the warning. -- There is no confirmation: nothing is lost. -- Undo: restore the submodule and choose `c` (committed pin), see - [Target](#target). +The same as [switching](#switch-branch) to the ref: the tip is staged +as the pin, the submodule and everything below it are restored to it, and +HEAD is put on the local branch. A local branch that is only behind is +fast-forwarded, so the common case (local `main` at the old pin) ends on +`main`. A checked-out branch that tracks the ref under another name (`dev` +tracking `origin/main`) is the one used, so the update stays on it. For the +superproject staged pins are lost, as in a switch (and counted). + +If the ref no longer points at the tip the row showed (a fetch meanwhile), +nothing happens: `can't update src/app: origin/main moved; check the new +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 -```sh -# in the submodule -git rev-parse HEAD # must still be the SHA shown in the UI, else abort -# in the parent -git update-index --cacheinfo 160000,, -``` - -`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. +The UI only: `startPinUpdate` (`internal/tui/pinupdate.go`) reads the +branches (`Switcher.Branches`), takes the one named like `Pin.Ref`, checks +that its tip still starts with the abbreviated `Status.Tip.SHA`, and then +runs the switch flow with it (`SwitchLoss`, `Switch`). ## Keys and palette -Both are submodule keys (`keyMap.Submodule`), shown in the detail pane's -palette, or in the footer when the pane can't show it. Restore is always -offered: it is idempotent, and the palette is static. +All three are submodule keys (`keyMap.Submodule`), shown in the detail +pane's palette, or in the footer when the pane can't show it. They are +always offered, the palette is static: restore is idempotent, and `u` says +why it can't update. | 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` | | `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 action, hence `x`. @@ -376,12 +390,19 @@ action, hence `x`. - 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. - Switch: provider (`switch_test.go`): list without `origin/HEAD`, local - first; a remote pick stages the tip and creates a tracking branch; an - existing local branch elsewhere is not moved; a moved ref aborts; the + first; a remote pick stages the tip and creates a tracking branch; a + 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 (`branches_test.go`): filter, selection, scrolling. UI (`switch_test.go`): opens, filters, enter switches, asks with a loss, esc closes, the mock 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 [architecture.md](architecture.md#testing). @@ -392,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 asks only if something would be lost. -Open (stage pin): - -1. **Key.** `s`? -2. **Palette.** Show it always (like restore), or only while it applies? +Open (update pin): updating several submodules at once (on the superproject +or a collapsed parent), deliberately left out for now. diff --git a/docs/architecture.md b/docs/architecture.md index ddd95ba..f644471 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -20,7 +20,7 @@ internal/submodule/git/ provider using the git CLI (reads, fetch, watch) fetch.go Fetch (network), Fetchable watch.go Watch: polls git metadata for changes 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 internal/submodule/mock/ fixture provider with artificial latency 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 keys.go key bindings (also feed the help view) 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 msgs.go tree/status/fetch/change messages refresh.go reloader: one load per key, coalesced reruns diff --git a/docs/decisions.md b/docs/decisions.md index 6d8d7dd..c02f828 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -362,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 is put on the branch. On the superproject it checks the branch out and 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 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). @@ -530,3 +530,40 @@ 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. diff --git a/docs/requirements.md b/docs/requirements.md index 29168f2..43af65e 100644 --- a/docs/requirements.md +++ b/docs/requirements.md @@ -156,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 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 diff --git a/docs/ux-guide.md b/docs/ux-guide.md index 28e6dd4..9f3257d 100644 --- a/docs/ux-guide.md +++ b/docs/ux-guide.md @@ -46,9 +46,10 @@ Keys come in three groups (`keyMap.Navigation`, `.Submodule`, `.Global`): release exists, `q quit and update to vX` follows them as the right-most item, on a highlighted background (`Theme.Highlight`: black on Primary), and plain quit is `Q` (see D27). -- **Submodule** (act on the selected one, e.g. `c`): a palette on the last - line of the detail pane. It is left out if the pane is shorter than 12 - lines. Whenever the palette isn't shown (pane hidden or too short), these +- **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. If the footer is too narrow, keys are dropped from the left group's end @@ -393,6 +394,16 @@ than `Muted`, so the popup has the focus. [restore](#restore): `checking`, the question `Switch src/app to 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 Every key in the UI, where it is shown. Update this @@ -410,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 | | `pgup`, `pgdn` | Move by one screen | Navigation | 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 | | `x` | Restore | Submodule | Palette, Help | | `f` | Fetch all repositories | Global | Help | diff --git a/internal/submodule/git/switch.go b/internal/submodule/git/switch.go index 0621ab3..40a3fcf 100644 --- a/internal/submodule/git/switch.go +++ b/internal/submodule/git/switch.go @@ -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 // 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 -// exists elsewhere is never moved: HEAD stays detached and "" is returned. +// local branch is the one checked out before if it tracks b, else the one +// 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) { dir, in, sub, err := p.switchDir(path) if err != nil { @@ -102,6 +105,10 @@ func (p *Provider) Switch(ctx context.Context, path string, b submodule.Branch) } else if !ok || sha != b.SHA { 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 _, err := p.exec(ctx, in.superDir, nil, "update-index", "--cacheinfo", "160000,"+b.SHA+","+in.relPath); err != nil { return "", err @@ -118,15 +125,51 @@ func (p *Provider) Switch(ctx context.Context, path string, b submodule.Branch) _, err := g("switch", "-q", b.Local) 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: return "", err case !ok: - _, err := g("switch", "-q", "-c", b.Local, "--track", b.Name) - return b.Local, err + _, err := g("switch", "-q", "-c", local, "--track", b.Name) + return local, err case tip == b.SHA: - _, err := g("switch", "-q", b.Local) - return b.Local, err + _, err := g("switch", "-q", local) + 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 } diff --git a/internal/submodule/git/switch_test.go b/internal/submodule/git/switch_test.go index b81f411..1471457 100644 --- a/internal/submodule/git/switch_test.go +++ b/internal/submodule/git/switch_test.go @@ -81,12 +81,15 @@ func TestSwitchSubmoduleStagesTipAndTracksRemoteBranch(t *testing.T) { assertExact(t, sub) } -func TestSwitchNeverMovesAnExistingLocalBranch(t *testing.T) { +func TestSwitchNeverMovesALocalBranchWithCommitsOfItsOwn(t *testing.T) { p, root := ownFixture(t) sub := filepath.Join(root, "super", "libs", "feature") 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") bs, err := p.Branches(ctx, "libs/feature") 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) { p, root := ownFixture(t) sub := filepath.Join(root, "super", "libs", "feature") diff --git a/internal/tui/app.go b/internal/tui/app.go index 4a06a4b..75d62ed 100644 --- a/internal/tui/app.go +++ b/internal/tui/app.go @@ -451,6 +451,8 @@ func (m Model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { return m.startRestore() case key.Matches(msg, k.Switch): return m.startSwitch() + case key.Matches(msg, k.Update): + return m.startPinUpdate() case key.Matches(msg, k.Issues): m.tree.SetProblemsOnly(!m.tree.ProblemsOnly()) m.layout() diff --git a/internal/tui/detail/detail.go b/internal/tui/detail/detail.go index f3d2659..cb7f126 100644 --- a/internal/tui/detail/detail.go +++ b/internal/tui/detail/detail.go @@ -55,20 +55,41 @@ func (m Model) View(s *submodule.Submodule, repo *submodule.Repo) string { lines[i] = ansi.Truncate(l, m.width, "…") } height := m.height - var palette string + var palette []string if m.PaletteVisible() { - height-- // the palette is the last line - m.help.Width = 0 - palette = ansi.Truncate(m.help.ShortHelpView(m.actions), m.width, "…") + palette = m.palette() + height -= len(palette) // the palette is at the bottom } if len(lines) > height { lines = lines[:height] } body := lipgloss.NewStyle().Width(m.width).Height(height).Render(strings.Join(lines, "\n")) - if palette == "" { + if len(palette) == 0 { 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 { diff --git a/internal/tui/detail/palette_test.go b/internal/tui/detail/palette_test.go new file mode 100644 index 0000000..c997e79 --- /dev/null +++ b/internal/tui/detail/palette_test.go @@ -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) + } +} diff --git a/internal/tui/keys.go b/internal/tui/keys.go index 3b44793..c46b43d 100644 --- a/internal/tui/keys.go +++ b/internal/tui/keys.go @@ -9,7 +9,7 @@ type keyMap struct { CollapseAll, ExpandAll key.Binding Home, End, PageUp, PageDown key.Binding Fetch, Reload, Filter, Issues key.Binding - Copy, Restore, Switch key.Binding + Copy, Restore, Switch, Update key.Binding Help, Quit, QuitUpdate key.Binding } @@ -32,6 +32,7 @@ func defaultKeys() keyMap { Issues: key.NewBinding(key.WithKeys("!"), key.WithHelp("!", "problems only")), Restore: key.NewBinding(key.WithKeys("x"), key.WithHelp("x", "restore")), 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")), Help: key.NewBinding(key.WithKeys("?"), key.WithHelp("?", "help")), Quit: key.NewBinding(key.WithKeys("q", "ctrl+c"), key.WithHelp("q", "quit")), @@ -56,7 +57,9 @@ func (k keyMap) Navigation() []key.Binding { // 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. -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. func (k keyMap) Global() []key.Binding { return []key.Binding{k.Help, k.Quit} } @@ -87,7 +90,7 @@ func (k keyMap) FullHelp() [][]key.Binding { {k.Up, k.Down, k.Left, k.Right, k.Toggle}, {k.CollapseAll, k.ExpandAll}, {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}, append([]key.Binding{k.Help, k.Quit}, k.fullUpdate()...), } diff --git a/internal/tui/pinupdate.go b/internal/tui/pinupdate.go new file mode 100644 index 0000000..cd8edd6 --- /dev/null +++ b/internal/tui/pinupdate.go @@ -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) +} diff --git a/internal/tui/pinupdate_test.go b/internal/tui/pinupdate_test.go new file mode 100644 index 0000000..6fe5cc1 --- /dev/null +++ b/internal/tui/pinupdate_test.go @@ -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()) + } +} diff --git a/internal/tui/restore.go b/internal/tui/restore.go index 6dbb501..559a443 100644 --- a/internal/tui/restore.go +++ b/internal/tui/restore.go @@ -28,13 +28,15 @@ type restoreState struct { to submodule.RestoreTarget loss submodule.Loss 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. type lossMsg struct { - path string - loss submodule.Loss - err error + path string + loss submodule.Loss + err error + branch *submodule.Branch // the branch a pin update switches to } type restoreDoneMsg struct { @@ -86,7 +88,7 @@ func (m Model) checkRestore() (tea.Model, tea.Cmd) { } else { 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()) } @@ -115,11 +117,18 @@ func (m Model) lossChecked(msg lossMsg) (tea.Model, tea.Cmd) { return m, nil } if msg.err != nil { + verb := "check" + if m.restore.pinUpdate { + verb = "update" + } 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() return m, nil } + if msg.branch != nil { + m.restore.branch = msg.branch + } m.restore.loss = msg.loss if msg.loss.Empty() { return m.runRestore() @@ -165,15 +174,15 @@ func (m Model) restoreDone(msg restoreDoneMsg) (tea.Model, tea.Cmd) { var cmds []tea.Cmd switch { 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: to := r.branch.Name if msg.branch != "" { to = msg.branch } 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: name := "the superproject" if r != nil { @@ -202,22 +211,42 @@ func (r *restoreState) toText() string { return "" } -// verb and verbIng word the operation: a switch is shown like a restore, but -// names its branch. +// verb, verbIng, verbEd and noun word the operation: a switch and a pin +// update are shown like a restore, but name their branch. func (r *restoreState) verb() string { - if r.branch != nil { + switch { + case r.pinUpdate: + return "Update" + case r.branch != nil: return "Switch" } return "Restore" } func (r *restoreState) verbIng() string { - if r.branch != nil { + switch { + case r.pinUpdate: + return "updating" + case r.branch != nil: return "switching" } 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 { if s == nil || s.IsRoot() { return "the superproject"