diff --git a/README.md b/README.md index 94ba1a7..54f0902 100644 --- a/README.md +++ b/README.md @@ -117,6 +117,10 @@ something would be lost, you are asked first: submodule with a staged pin, `x` first asks whether to restore to the staged pin (`s`) or the committed one (`c`, which unstages it). +`X` is a hard restore: it goes to the committed pins without asking which +one, and also drops the staged pins of submodules below the selection, +which `x` keeps where it can. It still asks if work would be lost. + ### Switch a branch with `b` Press `b` on a submodule (or the header for the superproject). A popup lists @@ -170,6 +174,7 @@ files. | `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) | +| `X` | Hard restore: like `x`, but to the committed pins, dropping all staged pins below | | `f` / `r` | Fetch / reload | | `?` | Full help | | `q` | Quit. If a newer release exists, the footer offers `q quit and update to vX` (highlighted) and plain quit moves to `Q` | diff --git a/docs/README.md b/docs/README.md index 7f629ff..5273f7e 100644 --- a/docs/README.md +++ b/docs/README.md @@ -33,7 +33,8 @@ 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, `b` switches its +submodules with problems. `x` restores the selection (`X` also drops +staged pins), `b` switches its branch and `u` updates a pin that is behind ([actions.md](actions.md)). Next steps, roughly in order: diff --git a/docs/actions.md b/docs/actions.md index 11b006b..beafc23 100644 --- a/docs/actions.md +++ b/docs/actions.md @@ -1,6 +1,7 @@ # Actions (draft) -**Status:** [Restore](#restore) (key `x`, [D23](decisions.md)), +**Status:** [Restore](#restore) (key `x`, [D23](decisions.md); hard restore +`X`, [D30](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)). @@ -13,7 +14,7 @@ a difference in one of two directions: | Action | Direction | Key | |---|---|---| -| [Restore](#restore) | The worktree follows the recorded state | `x` | +| [Restore](#restore) | The worktree follows the recorded state | `x`, `X` (hard) | | [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` | @@ -66,6 +67,32 @@ Two exceptions, where a staged pin is not kept: `.gitmodules` entry is reset with the other files. Its directory then counts as an untracked file. +### Hard restore + +`X` restores to the **committed** pins, at every depth: what the commits +record, without any staged pin. + +- The selected submodule goes to the committed pin, without the `s` / `c` + question; a staged pin of it is unstaged (as with `c`). A submodule that + was added but not committed yet has no committed pin and goes to the one + in the index. +- Every restored repository's index is reset to its `HEAD`, the superproject + included: its staged pins are dropped, and the submodules below follow the + committed tree. + +So `x` and `X` differ in the staged pins of repositories that the restore +does not move: `x` (`s` or `c`) keeps them, `X` drops them. This matters +for the superproject, for a selected submodule without a staged pin of its +own, and for nested repositories that are on their pin. A repository that +moves loses its staged pins either way (see above); then `x` → `c` and `X` +end the same, but `c` asks first. + +The dropped staged pins are counted in the loss (`2 staged pins`), but +alone they don't make restore ask: pressing `X` is the choice to drop them. +If something else would be lost, the question lists them with it. The +commits of a dropped pin stay in the submodule; if HEAD leaves them and no +ref reaches them, they count as `commits on no branch`, as always. + ### Postconditions After a successful restore of repository R with target T: @@ -132,7 +159,7 @@ Restore discards work. The things it can lose: | Untracked files | `changed` (untracked) | | Conflicts and in-progress operations | `conflict`; merge, rebase, cherry-pick, revert state | | Commits | A detached HEAD off the pin whose commits are on no branch, tag or remote ref (only the reflog still has them) | -| Staged pins | Staged pins in a repository that restore moves to another commit (see [Target](#target)) | +| Staged pins | Staged pins in a repository that restore moves to another commit (see [Target](#target)); with `X` all staged pins | | Repositories | An untracked directory that is a repository with its **own** `.git` directory (a clone that was never added as a submodule). Its commits may exist nowhere else. A removed submodule whose git directory is absorbed into `.git/modules` loses only its worktree. Its objects stay | Nothing else is lost. Branches, stashes, remote refs, ignored files and @@ -214,8 +241,10 @@ is resolved like `Status` does, and the first candidate (see accepts wins. The branch checked out before the restore is passed to the ref resolution. A child's `.gitmodules` branch is read once per parent. -With `c` the parent's index gets the committed pin first (`update-index ---cacheinfo` in the parent). +With `c` and `X` the parent's index gets the committed pin first +(`update-index --cacheinfo` in the parent). With `X` (`RestoreHard`) the +staged pins are not put back, and the loss check takes the children's +targets from `HEAD`'s tree instead of the index. `RestoreLoss` (`lossOf`) follows the repositories on disk: per repository `status` (without submodules), the in-progress state files, and diff --git a/docs/decisions.md b/docs/decisions.md index c02f828..f74e2c2 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -567,3 +567,29 @@ the branch. 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. + +## D30 — Hard restore drops staged pins at every depth + +**Context.** `x` keeps staged pins at every depth (D23), except in a +repository the restore moves. Its `c` answer unstages only the selected +submodule's pin. Staged pins of the superproject, or of nested repositories +that stay on their pin, could only be dropped one by one. +**Decision.** Amends D23. `X` is a hard restore: the selected submodule goes +to its committed pin without the `s` / `c` question, and every restored +repository's index is reset to `HEAD` (the superproject's too), so all +staged pins below are dropped and the submodules follow the committed +trees. Dropped staged pins are counted in the loss and listed if the +question appears, but alone they don't make it appear. `x` and its question +stay as they are. +**Rationale.** A separate key keeps the common `x` free of a further +question and makes the stronger action deliberate. A staged pin is only a +recorded SHA; its commit stays in the submodule, and if HEAD leaves a +commit no ref reaches, `commits on no branch` still asks. +**Alternatives.** A third answer in the `s` / `c` question: only reachable +for a submodule with a staged pin itself, not for the superproject or a +clean parent. Dropping the question from `x`: rejected, the choice between +staged and committed pin is still useful there. +**Consequences.** `RestoreTarget` gets `RestoreHard`, which also matters for +the superproject. `restoreRepo` and `lossOf` take whether to keep staged +pins; switch always keeps them. Without a committed pin (an added +submodule), `X` uses the index pin. diff --git a/docs/requirements.md b/docs/requirements.md index 43af65e..581d34c 100644 --- a/docs/requirements.md +++ b/docs/requirements.md @@ -148,6 +148,10 @@ commit hashes. It doesn't answer the questions users actually have: staged or the committed pin (the latter unstages it). A restored submodule whose pin is a branch tip ends on that branch, not detached. It asks for confirmation only if something would be lost. See [actions.md](actions.md#restore). + `X` is a hard restore: it goes to the committed pins at every depth, + without the pin question, and drops all staged pins below the selection + too. Dropped staged pins alone don't make it ask. See + [actions.md](actions.md#hard-restore). - **F57** `b` opens a popup in the middle of the screen with a search line and the local and remote branches of the selection (a submodule, or the diff --git a/docs/ux-guide.md b/docs/ux-guide.md index 9f3257d..7d3bc9c 100644 --- a/docs/ux-guide.md +++ b/docs/ux-guide.md @@ -295,6 +295,7 @@ colors in components. | `/` | Open the filter, see [Filter](#filter) | | `!` | Toggle problems only, see [Problems only](#problems-only) | | `x` | Restore the selection, see [Restore](#restore) | +| `X` | Hard restore the selection: committed pins, staged pins dropped at every depth, see [Restore](#restore) | "First child" always means the first child **as displayed** (children are grouped by directory). @@ -361,7 +362,9 @@ the term*. | failed | red `restore of src/app failed: `, until the next key | `y` restores, any other key cancels; the keys are not passed on while a -question is open. After `c` the name reads `src/app to the committed pin`. Without a loss there is no question. Success flashes +question is open. After `c` the name reads `src/app to the committed pin`. +`X` (hard restore) never asks for the pin; its name reads `src/app to the +committed pins`, and dropped staged pins alone don't make it ask. Without a loss there is no question. Success flashes `restored src/app` in the footer. For the header the name is `the superproject`. This line takes precedence over the filter line and the problems-only notice. @@ -424,6 +427,7 @@ where the palette can't be shown, see [Key bar and palette](#key-bar-and-palette | `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 | +| `X` | Hard restore | Submodule | Palette, Help | | `f` | Fetch all repositories | Global | Help | | `r` | Reload everything | Global | Help | | `/` | Filter | Global | Help | diff --git a/internal/submodule/git/restore.go b/internal/submodule/git/restore.go index 548a3e4..b99540f 100644 --- a/internal/submodule/git/restore.go +++ b/internal/submodule/git/restore.go @@ -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() diff --git a/internal/submodule/git/restore_test.go b/internal/submodule/git/restore_test.go index e9d6cc3..0290277 100644 --- a/internal/submodule/git/restore_test.go +++ b/internal/submodule/git/restore_test.go @@ -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") diff --git a/internal/submodule/git/switch.go b/internal/submodule/git/switch.go index 40a3fcf..6dbdd6a 100644 --- a/internal/submodule/git/switch.go +++ b/internal/submodule/git/switch.go @@ -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 } diff --git a/internal/submodule/provider.go b/internal/submodule/provider.go index a8003e6..1b978ff 100644 --- a/internal/submodule/provider.go +++ b/internal/submodule/provider.go @@ -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 diff --git a/internal/tui/app.go b/internal/tui/app.go index 75d62ed..56b3104 100644 --- a/internal/tui/app.go +++ b/internal/tui/app.go @@ -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): diff --git a/internal/tui/keys.go b/internal/tui/keys.go index c46b43d..917ba8f 100644 --- a/internal/tui/keys.go +++ b/internal/tui/keys.go @@ -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()...), } diff --git a/internal/tui/restore.go b/internal/tui/restore.go index 559a443..abce6a2 100644 --- a/internal/tui/restore.go +++ b/internal/tui/restore.go @@ -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 "" diff --git a/internal/tui/restore_test.go b/internal/tui/restore_test.go index 5d61ac0..3b718c0 100644 --- a/internal/tui/restore_test.go +++ b/internal/tui/restore_test.go @@ -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) + } +}