Archived
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:
@@ -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()
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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()...),
|
||||
}
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
|
||||
"submarine/internal/submodule"
|
||||
)
|
||||
|
||||
// startPinUpdate moves the pin of the selected submodule to the tip of its
|
||||
// ref, if the pin is only behind it: a switch to that branch, with the tip
|
||||
// the row shows. Nothing is fetched. For the superproject the pin is HEAD
|
||||
// and the ref its upstream: a fast-forward of its branch.
|
||||
func (m Model) startPinUpdate() (tea.Model, tea.Cmd) {
|
||||
s := m.tree.Selected()
|
||||
if s == nil {
|
||||
return m, nil
|
||||
}
|
||||
_, ok := m.provider.(submodule.Switcher)
|
||||
var flash string
|
||||
switch {
|
||||
case !ok:
|
||||
flash = "this data source can't update pins"
|
||||
case m.restore != nil:
|
||||
flash = "wait for the restore of " + m.restore.name + " to finish"
|
||||
default:
|
||||
flash = pinUpdateBlocker(s)
|
||||
}
|
||||
if flash != "" {
|
||||
cmd := m.footer.Flash(flash)
|
||||
m.layout()
|
||||
return m, cmd
|
||||
}
|
||||
|
||||
st := s.Status
|
||||
m.restore = &restoreState{path: s.Path, name: restoreName(s), pinUpdate: true, phase: restoreChecking}
|
||||
m.layout()
|
||||
ctx, path, ref, sha := m.ctx, s.Path, st.Pin.Ref, st.Tip.SHA
|
||||
sw := m.provider.(submodule.Switcher)
|
||||
check := func() tea.Msg {
|
||||
b, err := pinUpdateBranch(ctx, sw, path, ref, sha)
|
||||
if err != nil {
|
||||
return lossMsg{path: path, err: err}
|
||||
}
|
||||
loss, err := sw.SwitchLoss(ctx, path, *b)
|
||||
return lossMsg{path, loss, err, b}
|
||||
}
|
||||
return m, tea.Batch(check, m.tick())
|
||||
}
|
||||
|
||||
// pinUpdateBlocker says why the pin of s can't be updated, "" if it can.
|
||||
func pinUpdateBlocker(s *submodule.Submodule) string {
|
||||
st, name := s.Status, restoreName(s)
|
||||
pin := "the pin of " + name
|
||||
if s.IsRoot() {
|
||||
pin = name // its pin is HEAD
|
||||
}
|
||||
switch {
|
||||
case st == nil:
|
||||
return name + " is still loading"
|
||||
case st.Presence != submodule.PresenceOK:
|
||||
return name + " is not initialized; restore it first"
|
||||
case st.Pin.Ref == "" || st.Tip == nil || st.Pin.Source == submodule.RefFromTag || st.Pin.Source == submodule.RefFromSHA:
|
||||
if s.IsRoot() {
|
||||
return name + " has no upstream branch"
|
||||
}
|
||||
return pin + " is not on a branch"
|
||||
case st.Pin.Ahead > 0 && st.Pin.Behind > 0:
|
||||
return pin + " has diverged from " + st.Pin.Ref + "; b switches the branch"
|
||||
case st.Pin.Ahead > 0:
|
||||
return pin + " is ahead of " + st.Pin.Ref
|
||||
case st.Pin.Behind == 0:
|
||||
return name + " is up to date with " + st.Pin.Ref
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// pinUpdateBranch finds the branch ref among the branches of path. Its tip
|
||||
// must still be sha, the tip the row showed (abbreviated, see Commit).
|
||||
func pinUpdateBranch(ctx context.Context, sw submodule.Switcher, path, ref, sha string) (*submodule.Branch, error) {
|
||||
bs, err := sw.Branches(ctx, path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, b := range bs {
|
||||
if b.Name != ref {
|
||||
continue
|
||||
}
|
||||
if !b.Remote {
|
||||
return nil, fmt.Errorf("%s is a local branch; b switches to it", ref)
|
||||
}
|
||||
if !strings.HasPrefix(b.SHA, sha) {
|
||||
return nil, fmt.Errorf("%s moved; check the new state", ref)
|
||||
}
|
||||
return &b, nil
|
||||
}
|
||||
return nil, fmt.Errorf("%s not found", ref)
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
|
||||
"submarine/internal/submodule"
|
||||
)
|
||||
|
||||
// newPinUpdateModel is the switch model with the first submodule selected
|
||||
// and given st as its status.
|
||||
func newPinUpdateModel(t *testing.T, loss submodule.Loss, st *submodule.Status) (tea.Model, *fakeSwitcher, string) {
|
||||
t.Helper()
|
||||
m, f := newSwitchModel(t, loss)
|
||||
repo, err := f.Tree(context.Background())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
path := repo.Submodules[0].Path
|
||||
m, _ = m.Update(statusLoadedMsg{path: path, status: st})
|
||||
mm := m.(Model)
|
||||
mm.tree.Select(path)
|
||||
return mm, f, path
|
||||
}
|
||||
|
||||
// behind is a pin two commits behind origin/feat, whose tip is bbbbbbb.
|
||||
func behind() *submodule.Status {
|
||||
return &submodule.Status{
|
||||
Pin: submodule.PinInfo{Ref: "origin/feat", Behind: 2, Pushed: true, Source: submodule.RefFromGitmodules},
|
||||
Tip: &submodule.Commit{SHA: "bbbb"}, // abbreviated, like the git provider's
|
||||
}
|
||||
}
|
||||
|
||||
func TestPinUpdateSwitchesToTheTipOfTheRef(t *testing.T) {
|
||||
m, f, path := newPinUpdateModel(t, submodule.Loss{}, behind())
|
||||
m, cmd := press(m, "u")
|
||||
m = settle(m, cmd)
|
||||
if len(f.switched) != 1 || f.switched[0] != path+" refs/remotes/origin/feat" {
|
||||
t.Fatalf("switched %q", f.switched)
|
||||
}
|
||||
if v := m.View(); !strings.Contains(v, "updated "+path+" to feat") {
|
||||
t.Errorf("no flash:\n%s", v)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPinUpdateAsksWhenSomethingIsLost(t *testing.T) {
|
||||
m, f, path := newPinUpdateModel(t, submodule.Loss{Files: 1, Modules: 1}, behind())
|
||||
m, cmd := press(m, "u")
|
||||
for _, msg := range run(cmd) {
|
||||
if l, ok := msg.(lossMsg); ok {
|
||||
m, _ = m.Update(l)
|
||||
}
|
||||
}
|
||||
if v := m.View(); !strings.Contains(v, "Update "+path+" to origin/feat?") || !strings.Contains(v, "y update") {
|
||||
t.Errorf("question not shown:\n%s", v)
|
||||
}
|
||||
m, cmd = press(m, "y")
|
||||
feed(m, cmd)
|
||||
if len(f.switched) != 1 {
|
||||
t.Fatalf("switched %q", f.switched)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPinUpdateAbortsWhenTheRefMoved(t *testing.T) {
|
||||
st := behind()
|
||||
st.Tip.SHA = "ccccccc" // the row showed another tip than the branch has now
|
||||
m, f, _ := newPinUpdateModel(t, submodule.Loss{}, st)
|
||||
m, cmd := press(m, "u")
|
||||
m = settle(m, cmd)
|
||||
if len(f.switched) != 0 {
|
||||
t.Fatalf("switched %q", f.switched)
|
||||
}
|
||||
if v := m.View(); !strings.Contains(v, "origin/feat moved") {
|
||||
t.Errorf("no notice:\n%s", v)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPinUpdateOnlyWhenThePinIsBehind(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
edit func(*submodule.Status)
|
||||
want string
|
||||
}{
|
||||
{"up to date", func(s *submodule.Status) { s.Pin.Behind = 0 }, "is up to date with origin/feat"},
|
||||
{"ahead", func(s *submodule.Status) { s.Pin.Behind, s.Pin.Ahead = 0, 1 }, "is ahead of origin/feat"},
|
||||
{"diverged", func(s *submodule.Status) { s.Pin.Ahead = 1 }, "has diverged from origin/feat"},
|
||||
{"tag", func(s *submodule.Status) { s.Pin.Source = submodule.RefFromTag }, "is not on a branch"},
|
||||
{"missing", func(s *submodule.Status) { s.Presence = submodule.NotInitialized }, "is not initialized"},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
st := behind()
|
||||
tc.edit(st)
|
||||
m, f, _ := newPinUpdateModel(t, submodule.Loss{}, st)
|
||||
m, _ = press(m, "u") // the command is only the flash's timer
|
||||
if len(f.switched) != 0 || m.(Model).restore != nil {
|
||||
t.Fatalf("updated anyway: %q", f.switched)
|
||||
}
|
||||
if v := m.View(); !strings.Contains(v, tc.want) {
|
||||
t.Errorf("flash lacks %q:\n%s", tc.want, v)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPinUpdateOfTheSuperprojectFastForwardsItsBranch(t *testing.T) {
|
||||
m, f := newSwitchModel(t, submodule.Loss{}) // the superproject is selected
|
||||
st := behind()
|
||||
st.Root = true
|
||||
m, _ = m.Update(statusLoadedMsg{path: "", status: st})
|
||||
m, cmd := press(m, "u")
|
||||
m = settle(m, cmd)
|
||||
if len(f.switched) != 1 || f.switched[0] != " refs/remotes/origin/feat" {
|
||||
t.Fatalf("switched %q", f.switched)
|
||||
}
|
||||
if v := m.View(); !strings.Contains(v, "updated the superproject to feat") {
|
||||
t.Errorf("no flash:\n%s", v)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPinUpdateOfTheSuperprojectWithoutUpstreamSaysSo(t *testing.T) {
|
||||
m, f := newSwitchModel(t, submodule.Loss{})
|
||||
st := behind()
|
||||
st.Root, st.Pin.Source, st.Tip = true, submodule.RefFromSHA, nil
|
||||
m, _ = m.Update(statusLoadedMsg{path: "", status: st})
|
||||
m, _ = press(m, "u")
|
||||
if len(f.switched) != 0 || !strings.Contains(m.View(), "the superproject has no upstream branch") {
|
||||
t.Errorf("switched %q, view:\n%s", f.switched, m.View())
|
||||
}
|
||||
}
|
||||
|
||||
func TestPinUpdateRefusesALocalRef(t *testing.T) {
|
||||
st := behind()
|
||||
st.Pin.Ref, st.Tip.SHA = "main", "aaaa" // upstream is a local branch
|
||||
m, f, _ := newPinUpdateModel(t, submodule.Loss{}, st)
|
||||
m, cmd := press(m, "u")
|
||||
m = settle(m, cmd)
|
||||
if len(f.switched) != 0 || !strings.Contains(m.View(), "main is a local branch") {
|
||||
t.Errorf("switched %q, view:\n%s", f.switched, m.View())
|
||||
}
|
||||
}
|
||||
+41
-12
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user