Restore keeps staged pins; offer the committed pin for a staged one

Staged pins survive a restore at every depth and are the targets of the
submodules below. For a submodule with a staged pin, x asks whether to
restore to the staged or the committed pin, the latter unstaging it.
Staged pins of a repository that moves count as a loss.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-22 12:42:20 +02:00
co-authored by Claude Opus 5.5
parent 7980d3356a
commit b9f6055759
10 changed files with 359 additions and 72 deletions
+8 -3
View File
@@ -159,7 +159,7 @@ func (m *Model) startFetch() tea.Cmd {
// busy reports whether something shows a spinner.
func (m Model) busy() bool {
if m.repo == nil || m.fetch.Running || (m.restore != nil && m.restore.phase != restoreAsking) {
if m.repo == nil || m.fetch.Running || (m.restore != nil && (m.restore.phase == restoreChecking || m.restore.phase == restoreRunning)) {
return true
}
loading := m.repo.Root != nil && m.repo.Root.Status == nil && m.repo.Root.Err == nil
@@ -331,8 +331,13 @@ func (m Model) changed(msg changeMsg) (tea.Model, tea.Cmd) {
func (m Model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
k := m.keys
if m.restore != nil && m.restore.phase == restoreAsking {
return m.answerRestore(msg)
if m.restore != nil {
switch m.restore.phase {
case restoreChoosing:
return m.choosePin(msg)
case restoreAsking:
return m.answerRestore(msg)
}
}
if m.notice != "" {
m.notice = ""
+61 -10
View File
@@ -15,7 +15,8 @@ import (
type restorePhase int
const (
restoreChecking restorePhase = iota // reading what would be lost
restoreChoosing restorePhase = iota // staged or committed pin?
restoreChecking // reading what would be lost
restoreAsking // waiting for y / n
restoreRunning
)
@@ -24,6 +25,7 @@ const (
type restoreState struct {
path, name string // name is how the footer calls it
phase restorePhase
to submodule.RestoreTarget
loss submodule.Loss
}
@@ -40,10 +42,11 @@ type restoreDoneMsg struct {
}
// startRestore checks what restoring the selection would discard. If nothing
// would be lost it restores at once, else it asks first.
// would be lost it restores at once, else it asks first. A submodule with a
// staged pin first asks which pin to restore to.
func (m Model) startRestore() (tea.Model, tea.Cmd) {
s := m.tree.Selected()
r, ok := m.provider.(submodule.Restorer)
_, ok := m.provider.(submodule.Restorer)
var flash string
switch {
case s == nil:
@@ -60,15 +63,45 @@ func (m Model) startRestore() (tea.Model, tea.Cmd) {
}
m.restore = &restoreState{path: s.Path, name: restoreName(s)}
if !s.IsRoot() && s.Status != nil && s.Status.PinStaged {
m.restore.phase = restoreChoosing
m.layout()
return m, nil
}
return m.checkRestore()
}
// checkRestore reads what the restore would discard.
func (m Model) checkRestore() (tea.Model, tea.Cmd) {
m.restore.phase = restoreChecking
m.layout()
ctx, path := m.ctx, s.Path
r := m.provider.(submodule.Restorer)
ctx, path, to := m.ctx, m.restore.path, m.restore.to
check := func() tea.Msg {
loss, err := r.RestoreLoss(ctx, path)
loss, err := r.RestoreLoss(ctx, path, to)
return lossMsg{path, loss, err}
}
return m, tea.Batch(check, m.tick())
}
// choosePin handles a key while the restore asks for the pin: s the staged
// one, c the committed one (which unstages it), anything else cancels.
func (m Model) choosePin(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
switch {
case msg.Type == tea.KeyCtrlC:
return m, tea.Quit
case msg.String() == "s":
m.restore.to = submodule.RestoreStaged
return m.checkRestore()
case msg.String() == "c":
m.restore.to = submodule.RestoreCommitted
return m.checkRestore()
}
m.restore = nil
m.layout()
return m, nil
}
// lossChecked reacts to the check: restore, or ask.
func (m Model) lossChecked(msg lossMsg) (tea.Model, tea.Cmd) {
if m.restore == nil || m.restore.path != msg.path || m.restore.phase != restoreChecking {
@@ -107,8 +140,8 @@ func (m Model) runRestore() (tea.Model, tea.Cmd) {
m.restore.phase = restoreRunning
m.layout()
r := m.provider.(submodule.Restorer)
ctx, path := m.ctx, m.restore.path
run := func() tea.Msg { return restoreDoneMsg{path, r.Restore(ctx, path)} }
ctx, path, to := m.ctx, m.restore.path, m.restore.to
run := func() tea.Msg { return restoreDoneMsg{path, r.Restore(ctx, path, to)} }
return m, tea.Batch(run, m.tick())
}
@@ -117,7 +150,7 @@ func (m Model) runRestore() (tea.Model, tea.Cmd) {
func (m Model) restoreDone(msg restoreDoneMsg) (tea.Model, tea.Cmd) {
name := "the superproject"
if m.restore != nil {
name = m.restore.name
name = m.restore.name + m.restore.toText()
}
m.restore = nil
var cmds []tea.Cmd
@@ -131,6 +164,15 @@ func (m Model) restoreDone(msg restoreDoneMsg) (tea.Model, tea.Cmd) {
return m, tea.Batch(cmds...)
}
// toText is " to the committed pin" if that was chosen; the staged pin is the
// usual target and needs no words.
func (r *restoreState) toText() string {
if r.to == submodule.RestoreCommitted {
return " to the committed pin"
}
return ""
}
func restoreName(s *submodule.Submodule) string {
if s == nil || s.IsRoot() {
return "the superproject"
@@ -142,13 +184,19 @@ func restoreName(s *submodule.Submodule) string {
func (m Model) restoreView() string {
r := m.restore
left, right := "", ""
key := func(k string) string { return theme.Fg(m.theme.Text).Bold(true).Render(k) }
muted := func(s string) string { return theme.Fg(m.theme.Muted).Render(s) }
switch r.phase {
case restoreChoosing:
left = theme.Fg(m.theme.Warn).Render("Restore "+r.name+" to") + " " +
key("s") + muted(" staged pin · ") + key("c") + muted(" committed pin (unstages it)")
right = muted("any other key cancels")
case restoreChecking:
left = theme.Fg(m.theme.Primary).Render(m.spinner.View()) + " " + theme.Fg(m.theme.Muted).Render("checking "+r.name+"…")
case restoreRunning:
left = theme.Fg(m.theme.Primary).Render(m.spinner.View()) + " " + theme.Fg(m.theme.Warn).Render("restoring "+r.name+"…")
left = theme.Fg(m.theme.Primary).Render(m.spinner.View()) + " " + theme.Fg(m.theme.Warn).Render("restoring "+r.name+r.toText()+"…")
case restoreAsking:
left = theme.Fg(m.theme.Warn).Render("Restore "+r.name+"?") + " " +
left = theme.Fg(m.theme.Warn).Render("Restore "+r.name+r.toText()+"?") + " " +
theme.Fg(m.theme.Danger).Render("Discards "+lossText(r.loss)+".")
right = theme.Fg(m.theme.Text).Bold(true).Render("y") + theme.Fg(m.theme.Muted).Render(" restore · ") +
theme.Fg(m.theme.Text).Bold(true).Render("n") + theme.Fg(m.theme.Muted).Render(" cancel")
@@ -192,6 +240,9 @@ func lossText(l submodule.Loss) string {
if l.Repositories > 0 {
parts = append(parts, plural(l.Repositories, "untracked repository", "untracked repositories"))
}
if l.StagedPins > 0 {
parts = append(parts, plural(l.StagedPins, "staged pin", "staged pins"))
}
if l.Unknown > 0 {
parts = append(parts, plural(l.Unknown, "repository of unknown state", "repositories of unknown state"))
}
+38 -2
View File
@@ -25,6 +25,7 @@ func TestLossText(t *testing.T) {
{submodule.Loss{Files: 1, Modules: 1}, "1 changed file"},
{submodule.Loss{Files: 7, Modules: 3, Commits: 2, Repositories: 1}, "7 changed files in 3 repositories, 2 commits on no branch, 1 untracked repository"},
{submodule.Loss{Operations: 1, Modules: 1, Unknown: 2}, "1 operation in progress, 2 repositories of unknown state"},
{submodule.Loss{StagedPins: 2, Modules: 1}, "2 staged pins"},
}
for _, c := range cases {
if got := lossText(c.in); got != c.want {
@@ -38,14 +39,16 @@ type fakeRestorer struct {
submodule.Provider
loss submodule.Loss
restored []string
to []submodule.RestoreTarget
}
func (f *fakeRestorer) RestoreLoss(context.Context, string) (submodule.Loss, error) {
func (f *fakeRestorer) RestoreLoss(context.Context, string, submodule.RestoreTarget) (submodule.Loss, error) {
return f.loss, nil
}
func (f *fakeRestorer) Restore(_ context.Context, path string) error {
func (f *fakeRestorer) Restore(_ context.Context, path string, to submodule.RestoreTarget) error {
f.restored = append(f.restored, path)
f.to = append(f.to, to)
return nil
}
@@ -151,3 +154,36 @@ func TestRestoreWithoutRestorerSaysSo(t *testing.T) {
t.Fatal("the mock can't restore")
}
}
func TestRestoreOfAStagedPinAsksWhichPin(t *testing.T) {
m, f := newRestoreModel(t, submodule.Loss{})
mm := m.(Model)
var staged *submodule.Submodule
submodule.Walk(mm.repo.Submodules, func(s *submodule.Submodule) {
if staged == nil && s.Children == nil {
staged = s
}
})
staged.Status = &submodule.Status{PinStaged: true, Pin: submodule.PinInfo{Pushed: true}}
mm.tree.Select(staged.Path)
if mm.tree.Selected() != staged {
t.Fatalf("setup: selected %v", mm.tree.Selected())
}
m = mm
m, _ = press(m, "x")
if m.(Model).restore == nil || m.(Model).restore.phase != restoreChoosing {
t.Fatalf("want the pin question, got %+v", m.(Model).restore)
}
m, cmd := press(m, "c")
var next tea.Cmd
for _, msg := range run(cmd) {
if l, ok := msg.(lossMsg); ok {
m, next = m.Update(l)
}
}
feed(m, next)
if len(f.to) != 1 || f.to[0] != submodule.RestoreCommitted || f.restored[0] != staged.Path {
t.Fatalf("restored %q to %v, want %s to the committed pin", f.restored, f.to, staged.Path)
}
}