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>
This commit is contained in:
2026-10-24 22:39:18 +02:00
co-authored by Claude Opus 5.5
parent 99f1ba2bec
commit fbd233f58d
16 changed files with 686 additions and 105 deletions
+51 -8
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
// 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
}
+111 -2
View File
@@ -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")
+2
View File
@@ -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()
+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, "…")
}
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 {
+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)
}
}
+6 -3
View File
@@ -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()...),
}
+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
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"