Archived
Hard restore with X drops staged pins at every depth
X restores the selection to the committed pins without the staged or committed question, and resets every restored repository's index to HEAD, so staged pins below are dropped too. Dropped staged pins alone don't make it ask; they are listed if something else is lost. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -38,16 +38,17 @@ func (p *Provider) RestoreLoss(ctx context.Context, path string, to submodule.Re
|
||||
if dir == "" { // not initialized: nothing on disk to lose
|
||||
return submodule.Loss{}, nil
|
||||
}
|
||||
return p.lossOf(ctx, dir, target), nil
|
||||
return p.lossOf(ctx, dir, target, to.KeepsStaged()), nil
|
||||
}
|
||||
|
||||
// Restore makes path ("" is the superproject) and all submodules below it
|
||||
// exactly what git records, see docs/actions.md. Staged pins are kept. With
|
||||
// RestoreCommitted, a submodule's own staged pin is unstaged first. A missing
|
||||
// submodule is cloned and missing commits are fetched; git never prompts.
|
||||
// RestoreCommitted, a submodule's own staged pin is unstaged first; with
|
||||
// RestoreHard, the staged pins below are dropped too. A missing submodule is
|
||||
// cloned and missing commits are fetched; git never prompts.
|
||||
func (p *Provider) Restore(ctx context.Context, path string, to submodule.RestoreTarget) error {
|
||||
if path == "" {
|
||||
return p.restoreRepo(ctx, p.root, "HEAD", nil)
|
||||
return p.restoreRepo(ctx, p.root, "HEAD", nil, to.KeepsStaged())
|
||||
}
|
||||
p.mu.RLock()
|
||||
in, ok := p.mods[path]
|
||||
@@ -59,7 +60,7 @@ func (p *Provider) Restore(ctx context.Context, path string, to submodule.Restor
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if to == submodule.RestoreCommitted {
|
||||
if to.Committed() {
|
||||
if _, err := p.exec(ctx, in.superDir, nil, "update-index", "--cacheinfo", "160000,"+pin+","+in.relPath); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -69,7 +70,7 @@ func (p *Provider) Restore(ctx context.Context, path string, to submodule.Restor
|
||||
return fmt.Errorf("%s: %w", path, err)
|
||||
}
|
||||
}
|
||||
return p.restoreRepo(ctx, in.dir, pin, &in)
|
||||
return p.restoreRepo(ctx, in.dir, pin, &in, to.KeepsStaged())
|
||||
}
|
||||
|
||||
// restoreTarget returns the directory and the commit path is restored to:
|
||||
@@ -96,9 +97,10 @@ func (p *Provider) restoreTarget(ctx context.Context, path string, to submodule.
|
||||
}
|
||||
|
||||
// pinOf returns the gitlink of a submodule in its parent's index (staged)
|
||||
// or HEAD (committed).
|
||||
// or HEAD (committed). A hard restore of a submodule that was added but not
|
||||
// committed yet uses the index: there is no staged pin to drop.
|
||||
func pinOf(ctx context.Context, in info, to submodule.RestoreTarget) (string, error) {
|
||||
if to == submodule.RestoreCommitted {
|
||||
if to.Committed() {
|
||||
out, _, err := try(ctx, in.superDir, "ls-tree", "HEAD", "--", in.relPath)
|
||||
if err != nil {
|
||||
return "", err
|
||||
@@ -106,7 +108,9 @@ func pinOf(ctx context.Context, in info, to submodule.RestoreTarget) (string, er
|
||||
if pin := parseGitlink(out); pin != "" {
|
||||
return pin, nil
|
||||
}
|
||||
return "", errors.New("has no committed pin (added, but not committed yet)")
|
||||
if to != submodule.RestoreHard {
|
||||
return "", errors.New("has no committed pin (added, but not committed yet)")
|
||||
}
|
||||
}
|
||||
out, err := run(ctx, in.superDir, "ls-files", "-s", "--", in.relPath)
|
||||
if err != nil {
|
||||
@@ -124,10 +128,11 @@ func pinOf(ctx context.Context, in info, to submodule.RestoreTarget) (string, er
|
||||
// restoreRepo restores the initialized repository at dir to target (a commit
|
||||
// or "HEAD") and then every submodule its index records. Branches are never
|
||||
// moved: a checkout that is not on target is detached first. If the checkout
|
||||
// stays on target, its staged pins are kept and are the submodules' targets.
|
||||
// stays on target and keep is set, its staged pins are kept and are the
|
||||
// submodules' targets; else the submodules follow target's tree.
|
||||
// For a submodule (in is not nil) a detached HEAD is then put on a branch at
|
||||
// target if there is one, see attachBranch; the submodules below always are.
|
||||
func (p *Provider) restoreRepo(ctx context.Context, dir, target string, in *info) error {
|
||||
func (p *Provider) restoreRepo(ctx context.Context, dir, target string, in *info, keep bool) error {
|
||||
env := p.networkEnv()
|
||||
g := func(args ...string) (string, error) {
|
||||
return p.exec(ctx, dir, env, append(append([]string{}, restoreArgs...), args...)...)
|
||||
@@ -143,18 +148,20 @@ func (p *Provider) restoreRepo(ctx context.Context, dir, target string, in *info
|
||||
p.clearOperations(ctx, dir, gitDir)
|
||||
}
|
||||
prev, _, _ := try(ctx, dir, "symbolic-ref", "--short", "-q", "HEAD") // for the ref, see attachBranch
|
||||
var keep []gitlink
|
||||
var kept []gitlink
|
||||
if head, err := g("rev-parse", "--verify", "-q", "HEAD"); err != nil || head != sha {
|
||||
if _, err := g("checkout", "--force", "--detach", sha); err != nil {
|
||||
return err
|
||||
}
|
||||
} else if keep, err = stagedPins(ctx, dir); err != nil {
|
||||
return err
|
||||
} else if keep {
|
||||
if kept, err = stagedPins(ctx, dir); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if _, err := g("reset", "--hard", "-q"); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, l := range keep {
|
||||
for _, l := range kept {
|
||||
if _, err := g("update-index", "--cacheinfo", "160000,"+l.sha+","+l.path); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -206,7 +213,7 @@ func (p *Provider) restoreRepo(ctx context.Context, dir, target string, in *info
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
child := info{superDir: dir, dir: filepath.Join(dir, filepath.FromSlash(l.path)), relPath: l.path, branch: branches[l.path]}
|
||||
if err := p.restoreRepo(ctx, child.dir, l.sha, &child); err != nil {
|
||||
if err := p.restoreRepo(ctx, child.dir, l.sha, &child, keep); err != nil {
|
||||
errs[i] = err
|
||||
}
|
||||
}()
|
||||
@@ -482,8 +489,9 @@ func stagedPins(ctx context.Context, dir string) ([]gitlink, error) {
|
||||
|
||||
// lossOf adds up what a restore of the repository at dir to target
|
||||
// discards, including the submodules on disk below it. target is "" if it is
|
||||
// not known; then commits are only judged by the refs that reach them.
|
||||
func (p *Provider) lossOf(ctx context.Context, dir, target string) submodule.Loss {
|
||||
// not known; then commits are only judged by the refs that reach them. keep
|
||||
// is false for a hard restore, which drops all staged pins.
|
||||
func (p *Provider) lossOf(ctx context.Context, dir, target string, keep bool) submodule.Loss {
|
||||
var own submodule.Loss
|
||||
var children []gitlink
|
||||
var targets map[string]string
|
||||
@@ -545,16 +553,19 @@ func (p *Provider) lossOf(ctx context.Context, dir, target string) submodule.Los
|
||||
}
|
||||
|
||||
// staged pins are kept, unless the checkout moves away from
|
||||
// the commit they were staged on; then the submodules follow
|
||||
// the target's tree
|
||||
// the commit they were staged on or the restore is hard; then
|
||||
// the submodules follow the target's tree
|
||||
staged, err := stagedPins(ctx, dir)
|
||||
if err != nil {
|
||||
unknown()
|
||||
}
|
||||
var links []gitlink
|
||||
switch {
|
||||
case !moves:
|
||||
case !moves && keep:
|
||||
links, err = indexGitlinks(ctx, dir)
|
||||
case !moves:
|
||||
own.StagedPins = len(staged)
|
||||
links, err = treeGitlinks(ctx, dir, "HEAD")
|
||||
case target != "":
|
||||
own.StagedPins = len(staged)
|
||||
links, err = treeGitlinks(ctx, dir, target)
|
||||
@@ -589,7 +600,7 @@ func (p *Provider) lossOf(ctx context.Context, dir, target string) submodule.Los
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
results[i] = p.lossOf(ctx, sub, targets[c.path])
|
||||
results[i] = p.lossOf(ctx, sub, targets[c.path], keep)
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
@@ -310,6 +310,60 @@ func TestRestoreCommittedUnstagesThePin(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRestoreHardDropsStagedPinsAtEveryDepth(t *testing.T) {
|
||||
p, root := ownFixture(t)
|
||||
super := filepath.Join(root, "super")
|
||||
log := filepath.Join(super, "libs", "log")
|
||||
nested := filepath.Join(log, "modules", "nested")
|
||||
committed := out(t, nested, "rev-parse", "HEAD")
|
||||
base, err := p.RestoreLoss(context.Background(), "", submodule.RestoreHard)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// stage a new pin of nested in libs/log, on a commit only nested's
|
||||
// detached HEAD reaches
|
||||
git(t, nested, "checkout", "-q", "--detach")
|
||||
git(t, nested, "commit", "-q", "--allow-empty", "-m", "Nested work")
|
||||
git(t, log, "add", "modules/nested")
|
||||
|
||||
l, err := p.RestoreLoss(context.Background(), "", submodule.RestoreHard)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if base.StagedPins == 0 || l.StagedPins != base.StagedPins+1 || l.Commits != base.Commits+1 {
|
||||
t.Errorf("loss %+v, want the fixture's %d staged pins, the nested one and its commit", l, base.StagedPins)
|
||||
}
|
||||
if err := p.Restore(context.Background(), "", submodule.RestoreHard); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, dir := range []string{super, log} {
|
||||
if got := out(t, dir, "diff", "--cached", "--name-only"); got != "" {
|
||||
t.Errorf("%s still staged: %q", dir, got)
|
||||
}
|
||||
}
|
||||
if got := out(t, nested, "rev-parse", "HEAD"); got != committed {
|
||||
t.Errorf("nested HEAD = %s, want the committed pin %s", got, committed)
|
||||
}
|
||||
assertExact(t, super)
|
||||
}
|
||||
|
||||
func TestRestoreHardOfASubmoduleUnstagesItsPin(t *testing.T) {
|
||||
p, root := ownFixture(t)
|
||||
super := filepath.Join(root, "super")
|
||||
committed := strings.Fields(out(t, super, "ls-tree", "HEAD", "libs/staged"))[2]
|
||||
|
||||
if err := p.Restore(context.Background(), "libs/staged", submodule.RestoreHard); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := out(t, super, "diff", "--cached", "--name-only"); strings.Contains(got, "libs/staged") {
|
||||
t.Errorf("still staged: %q", got)
|
||||
}
|
||||
if got := out(t, filepath.Join(super, "libs", "staged"), "rev-parse", "HEAD"); got != committed {
|
||||
t.Errorf("HEAD = %s, want the committed pin %s", got, committed)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRestoreLossCountsStagedPinsOfAMovingRepository(t *testing.T) {
|
||||
p, root := ownFixture(t)
|
||||
super := filepath.Join(root, "super")
|
||||
|
||||
@@ -85,7 +85,7 @@ func (p *Provider) SwitchLoss(ctx context.Context, path string, b submodule.Bran
|
||||
if err != nil {
|
||||
return submodule.Loss{}, err
|
||||
}
|
||||
return p.lossOf(ctx, dir, b.SHA), nil
|
||||
return p.lossOf(ctx, dir, b.SHA, true), nil
|
||||
}
|
||||
|
||||
// Switch makes b's tip the staged pin of a submodule, restores the repository
|
||||
@@ -114,7 +114,7 @@ func (p *Provider) Switch(ctx context.Context, path string, b submodule.Branch)
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
if err := p.restoreRepo(ctx, dir, b.SHA, nil); err != nil { // HEAD goes on b below
|
||||
if err := p.restoreRepo(ctx, dir, b.SHA, nil, true); err != nil { // HEAD goes on b below
|
||||
return "", err
|
||||
}
|
||||
|
||||
|
||||
@@ -72,15 +72,24 @@ type Switcher interface {
|
||||
Switch(ctx context.Context, path string, b Branch) (string, error)
|
||||
}
|
||||
|
||||
// RestoreTarget is the pin a submodule is restored to. It doesn't matter for
|
||||
// the superproject, whose target is always HEAD.
|
||||
// RestoreTarget is the pin a submodule is restored to, and whether staged
|
||||
// pins below it are kept. The superproject's target is always HEAD; only
|
||||
// RestoreHard matters for it.
|
||||
type RestoreTarget int
|
||||
|
||||
const (
|
||||
RestoreStaged RestoreTarget = iota // the pin in the parent's index: a staged pin is kept and used
|
||||
RestoreCommitted // the pin in the parent's HEAD: a staged pin is unstaged
|
||||
RestoreHard // the pin in the parent's HEAD, and staged pins below are dropped too
|
||||
)
|
||||
|
||||
// Committed reports whether a submodule goes to the pin in its parent's HEAD.
|
||||
func (t RestoreTarget) Committed() bool { return t != RestoreStaged }
|
||||
|
||||
// KeepsStaged reports whether staged pins below the restored repository are
|
||||
// kept.
|
||||
func (t RestoreTarget) KeepsStaged() bool { return t != RestoreHard }
|
||||
|
||||
// Loss is what a restore discards. The zero Loss means nothing is lost.
|
||||
type Loss struct {
|
||||
Files int // changed, conflicted and untracked files and directories
|
||||
|
||||
+3
-1
@@ -448,7 +448,9 @@ func (m Model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
m.layout()
|
||||
return m, m.filter.Focus()
|
||||
case key.Matches(msg, k.Restore):
|
||||
return m.startRestore()
|
||||
return m.startRestore(false)
|
||||
case key.Matches(msg, k.HardRestore):
|
||||
return m.startRestore(true)
|
||||
case key.Matches(msg, k.Switch):
|
||||
return m.startSwitch()
|
||||
case key.Matches(msg, k.Update):
|
||||
|
||||
@@ -10,6 +10,7 @@ type keyMap struct {
|
||||
Home, End, PageUp, PageDown key.Binding
|
||||
Fetch, Reload, Filter, Issues key.Binding
|
||||
Copy, Restore, Switch, Update key.Binding
|
||||
HardRestore key.Binding
|
||||
Help, Quit, QuitUpdate key.Binding
|
||||
}
|
||||
|
||||
@@ -31,6 +32,7 @@ func defaultKeys() keyMap {
|
||||
Filter: key.NewBinding(key.WithKeys("/"), key.WithHelp("/", "filter")),
|
||||
Issues: key.NewBinding(key.WithKeys("!"), key.WithHelp("!", "problems only")),
|
||||
Restore: key.NewBinding(key.WithKeys("x"), key.WithHelp("x", "restore")),
|
||||
HardRestore: key.NewBinding(key.WithKeys("X"), key.WithHelp("X", "hard 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")),
|
||||
@@ -58,7 +60,7 @@ 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.Update, k.Switch, k.Restore}
|
||||
return []key.Binding{k.Copy, k.Update, k.Switch, k.Restore, k.HardRestore}
|
||||
}
|
||||
|
||||
// Global returns the bindings that don't depend on the selection.
|
||||
@@ -90,7 +92,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.Update, k.Switch, k.Restore},
|
||||
{k.Copy, k.Update, k.Switch, k.Restore, k.HardRestore},
|
||||
{k.Fetch, k.Reload, k.Filter, k.Issues},
|
||||
append([]key.Binding{k.Help, k.Quit}, k.fullUpdate()...),
|
||||
}
|
||||
|
||||
+22
-8
@@ -47,8 +47,9 @@ type restoreDoneMsg struct {
|
||||
|
||||
// startRestore checks what restoring the selection would discard. If nothing
|
||||
// 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) {
|
||||
// staged pin first asks which pin to restore to; a hard restore doesn't: it
|
||||
// goes to the committed pins, at every depth.
|
||||
func (m Model) startRestore(hard bool) (tea.Model, tea.Cmd) {
|
||||
s := m.tree.Selected()
|
||||
_, ok := m.provider.(submodule.Restorer)
|
||||
var flash string
|
||||
@@ -67,6 +68,10 @@ func (m Model) startRestore() (tea.Model, tea.Cmd) {
|
||||
}
|
||||
|
||||
m.restore = &restoreState{path: s.Path, name: restoreName(s)}
|
||||
if hard {
|
||||
m.restore.to = submodule.RestoreHard
|
||||
return m.checkRestore()
|
||||
}
|
||||
if !s.IsRoot() && s.Status != nil && s.Status.PinStaged {
|
||||
m.restore.phase = restoreChoosing
|
||||
m.layout()
|
||||
@@ -130,7 +135,13 @@ func (m Model) lossChecked(msg lossMsg) (tea.Model, tea.Cmd) {
|
||||
m.restore.branch = msg.branch
|
||||
}
|
||||
m.restore.loss = msg.loss
|
||||
if msg.loss.Empty() {
|
||||
// a hard restore is asked for to drop staged pins: alone they are no
|
||||
// reason to ask, but are listed if something else is lost
|
||||
lost := msg.loss
|
||||
if m.restore.to == submodule.RestoreHard {
|
||||
lost.StagedPins, lost.Modules = 0, 0
|
||||
}
|
||||
if lost.Empty() {
|
||||
return m.runRestore()
|
||||
}
|
||||
m.restore.phase = restoreAsking
|
||||
@@ -199,13 +210,16 @@ func (m Model) restoreDone(msg restoreDoneMsg) (tea.Model, tea.Cmd) {
|
||||
return m, tea.Batch(cmds...)
|
||||
}
|
||||
|
||||
// toText is " to origin/feat" for a switch, " to the committed pin" if that was chosen; the staged pin is the
|
||||
// usual target and needs no words.
|
||||
// toText is " to origin/feat" for a switch, " to the committed pin" if that
|
||||
// was chosen, " to the committed pins" for a hard restore; the staged pin is
|
||||
// the usual target and needs no words.
|
||||
func (r *restoreState) toText() string {
|
||||
if r.branch != nil {
|
||||
switch {
|
||||
case r.branch != nil:
|
||||
return " to " + r.branch.Name
|
||||
}
|
||||
if r.to == submodule.RestoreCommitted {
|
||||
case r.to == submodule.RestoreHard:
|
||||
return " to the committed pins"
|
||||
case r.to == submodule.RestoreCommitted:
|
||||
return " to the committed pin"
|
||||
}
|
||||
return ""
|
||||
|
||||
@@ -3,6 +3,7 @@ package tui
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
@@ -187,3 +188,47 @@ func TestRestoreOfAStagedPinAsksWhichPin(t *testing.T) {
|
||||
t.Fatalf("restored %q to %v, want %s to the committed pin", f.restored, f.to, staged.Path)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHardRestoreOfAStagedPinDoesNotAsk(t *testing.T) {
|
||||
m, f := newRestoreModel(t, submodule.Loss{StagedPins: 2, Modules: 2})
|
||||
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)
|
||||
m = mm
|
||||
|
||||
// neither which pin, nor y/n for staged pins alone
|
||||
m, cmd := press(m, "X")
|
||||
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.RestoreHard || f.restored[0] != staged.Path {
|
||||
t.Fatalf("restored %q to %v, want %s hard", f.restored, f.to, staged.Path)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHardRestoreAsksWhenWorkIsLost(t *testing.T) {
|
||||
m, _ := newRestoreModel(t, submodule.Loss{Files: 1, StagedPins: 1, Modules: 1})
|
||||
m, cmd := press(m, "X")
|
||||
for _, msg := range run(cmd) {
|
||||
if l, ok := msg.(lossMsg); ok {
|
||||
m, _ = m.Update(l)
|
||||
}
|
||||
}
|
||||
r := m.(Model).restore
|
||||
if r == nil || r.phase != restoreAsking {
|
||||
t.Fatalf("want a question, got %+v", r)
|
||||
}
|
||||
if got := lossText(r.loss); !strings.Contains(got, "1 staged pin") {
|
||||
t.Errorf("question %q doesn't list the staged pin", got)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user