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:
@@ -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
|
submodule with a staged pin, `x` first asks whether to restore to the staged
|
||||||
pin (`s`) or the committed one (`c`, which unstages it).
|
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`
|
### Switch a branch with `b`
|
||||||
|
|
||||||
Press `b` on a submodule (or the header for the superproject). A popup lists
|
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 |
|
| `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 |
|
| `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` | 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 |
|
| `f` / `r` | Fetch / reload |
|
||||||
| `?` | Full help |
|
| `?` | 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` |
|
| `q` | Quit. If a newer release exists, the footer offers `q quit and update to vX` (highlighted) and plain quit moves to `Q` |
|
||||||
|
|||||||
+2
-1
@@ -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
|
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
|
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
|
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)).
|
branch and `u` updates a pin that is behind ([actions.md](actions.md)).
|
||||||
|
|
||||||
Next steps, roughly in order:
|
Next steps, roughly in order:
|
||||||
|
|||||||
+34
-5
@@ -1,6 +1,7 @@
|
|||||||
# Actions (draft)
|
# 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
|
[Switch branch](#switch-branch) (key `b`, [D24](decisions.md)) and
|
||||||
[Update pin](#update-pin) (key `u`, [D29](decisions.md)) are implemented.
|
[Update pin](#update-pin) (key `u`, [D29](decisions.md)) are implemented.
|
||||||
Stage pin was dropped ([D29](decisions.md)).
|
Stage pin was dropped ([D29](decisions.md)).
|
||||||
@@ -13,7 +14,7 @@ a difference in one of two directions:
|
|||||||
|
|
||||||
| Action | Direction | Key |
|
| 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` |
|
| [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` |
|
| [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
|
`.gitmodules` entry is reset with the other files. Its directory then
|
||||||
counts as an untracked file.
|
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
|
### Postconditions
|
||||||
|
|
||||||
After a successful restore of repository R with target T:
|
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) |
|
| Untracked files | `changed` (untracked) |
|
||||||
| Conflicts and in-progress operations | `conflict`; merge, rebase, cherry-pick, revert state |
|
| 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) |
|
| 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 |
|
| 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
|
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
|
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.
|
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
|
With `c` and `X` the parent's index gets the committed pin first
|
||||||
--cacheinfo` in the parent).
|
(`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
|
`RestoreLoss` (`lossOf`) follows the repositories on disk: per repository
|
||||||
`status` (without submodules), the in-progress state files, and
|
`status` (without submodules), the in-progress state files, and
|
||||||
|
|||||||
@@ -567,3 +567,29 @@ the branch.
|
|||||||
its own rule. No new provider interface: `u` is `Branches`, `SwitchLoss` and
|
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
|
`Switch`. Since `Status.Tip.SHA` is abbreviated, the UI compares it as a
|
||||||
prefix of the branch's full SHA.
|
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.
|
||||||
|
|||||||
@@ -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
|
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
|
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).
|
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
|
- **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
|
and the local and remote branches of the selection (a submodule, or the
|
||||||
|
|||||||
+5
-1
@@ -295,6 +295,7 @@ colors in components.
|
|||||||
| `/` | Open the filter, see [Filter](#filter) |
|
| `/` | Open the filter, see [Filter](#filter) |
|
||||||
| `!` | Toggle problems only, see [Problems only](#problems-only) |
|
| `!` | Toggle problems only, see [Problems only](#problems-only) |
|
||||||
| `x` | Restore the selection, see [Restore](#restore) |
|
| `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
|
"First child" always means the first child **as displayed** (children are
|
||||||
grouped by directory).
|
grouped by directory).
|
||||||
@@ -361,7 +362,9 @@ the term*.
|
|||||||
| failed | red `restore of src/app failed: <git's message>`, until the next key |
|
| failed | red `restore of src/app failed: <git's message>`, until the next key |
|
||||||
|
|
||||||
`y` restores, any other key cancels; the keys are not passed on while a
|
`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
|
`restored src/app` in the footer. For the header the name is `the
|
||||||
superproject`. This line takes precedence over the filter line and the
|
superproject`. This line takes precedence over the filter line and the
|
||||||
problems-only notice.
|
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 |
|
| `u` | Update pin to the tip of its ref (superproject: fast-forward to upstream) | Submodule | Palette, Help |
|
||||||
| `b` | Switch branch (popup) | Submodule | Palette, Help |
|
| `b` | Switch branch (popup) | Submodule | Palette, Help |
|
||||||
| `x` | Restore | Submodule | Palette, Help |
|
| `x` | Restore | Submodule | Palette, Help |
|
||||||
|
| `X` | Hard restore | Submodule | Palette, Help |
|
||||||
| `f` | Fetch all repositories | Global | Help |
|
| `f` | Fetch all repositories | Global | Help |
|
||||||
| `r` | Reload everything | Global | Help |
|
| `r` | Reload everything | Global | Help |
|
||||||
| `/` | Filter | Global | Help |
|
| `/` | Filter | Global | Help |
|
||||||
|
|||||||
@@ -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
|
if dir == "" { // not initialized: nothing on disk to lose
|
||||||
return submodule.Loss{}, nil
|
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
|
// Restore makes path ("" is the superproject) and all submodules below it
|
||||||
// exactly what git records, see docs/actions.md. Staged pins are kept. With
|
// 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
|
// RestoreCommitted, a submodule's own staged pin is unstaged first; with
|
||||||
// submodule is cloned and missing commits are fetched; git never prompts.
|
// 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 {
|
func (p *Provider) Restore(ctx context.Context, path string, to submodule.RestoreTarget) error {
|
||||||
if path == "" {
|
if path == "" {
|
||||||
return p.restoreRepo(ctx, p.root, "HEAD", nil)
|
return p.restoreRepo(ctx, p.root, "HEAD", nil, to.KeepsStaged())
|
||||||
}
|
}
|
||||||
p.mu.RLock()
|
p.mu.RLock()
|
||||||
in, ok := p.mods[path]
|
in, ok := p.mods[path]
|
||||||
@@ -59,7 +60,7 @@ func (p *Provider) Restore(ctx context.Context, path string, to submodule.Restor
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
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 {
|
if _, err := p.exec(ctx, in.superDir, nil, "update-index", "--cacheinfo", "160000,"+pin+","+in.relPath); err != nil {
|
||||||
return err
|
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 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:
|
// 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)
|
// 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) {
|
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)
|
out, _, err := try(ctx, in.superDir, "ls-tree", "HEAD", "--", in.relPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
@@ -106,7 +108,9 @@ func pinOf(ctx context.Context, in info, to submodule.RestoreTarget) (string, er
|
|||||||
if pin := parseGitlink(out); pin != "" {
|
if pin := parseGitlink(out); pin != "" {
|
||||||
return pin, nil
|
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)
|
out, err := run(ctx, in.superDir, "ls-files", "-s", "--", in.relPath)
|
||||||
if err != nil {
|
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
|
// restoreRepo restores the initialized repository at dir to target (a commit
|
||||||
// or "HEAD") and then every submodule its index records. Branches are never
|
// 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
|
// 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
|
// 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.
|
// 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()
|
env := p.networkEnv()
|
||||||
g := func(args ...string) (string, error) {
|
g := func(args ...string) (string, error) {
|
||||||
return p.exec(ctx, dir, env, append(append([]string{}, restoreArgs...), args...)...)
|
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)
|
p.clearOperations(ctx, dir, gitDir)
|
||||||
}
|
}
|
||||||
prev, _, _ := try(ctx, dir, "symbolic-ref", "--short", "-q", "HEAD") // for the ref, see attachBranch
|
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 head, err := g("rev-parse", "--verify", "-q", "HEAD"); err != nil || head != sha {
|
||||||
if _, err := g("checkout", "--force", "--detach", sha); err != nil {
|
if _, err := g("checkout", "--force", "--detach", sha); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
} else if keep, err = stagedPins(ctx, dir); err != nil {
|
} else if keep {
|
||||||
return err
|
if kept, err = stagedPins(ctx, dir); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if _, err := g("reset", "--hard", "-q"); err != nil {
|
if _, err := g("reset", "--hard", "-q"); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
for _, l := range keep {
|
for _, l := range kept {
|
||||||
if _, err := g("update-index", "--cacheinfo", "160000,"+l.sha+","+l.path); err != nil {
|
if _, err := g("update-index", "--cacheinfo", "160000,"+l.sha+","+l.path); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -206,7 +213,7 @@ func (p *Provider) restoreRepo(ctx context.Context, dir, target string, in *info
|
|||||||
go func() {
|
go func() {
|
||||||
defer wg.Done()
|
defer wg.Done()
|
||||||
child := info{superDir: dir, dir: filepath.Join(dir, filepath.FromSlash(l.path)), relPath: l.path, branch: branches[l.path]}
|
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
|
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
|
// 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
|
// 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.
|
// not known; then commits are only judged by the refs that reach them. keep
|
||||||
func (p *Provider) lossOf(ctx context.Context, dir, target string) submodule.Loss {
|
// 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 own submodule.Loss
|
||||||
var children []gitlink
|
var children []gitlink
|
||||||
var targets map[string]string
|
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
|
// staged pins are kept, unless the checkout moves away from
|
||||||
// the commit they were staged on; then the submodules follow
|
// the commit they were staged on or the restore is hard; then
|
||||||
// the target's tree
|
// the submodules follow the target's tree
|
||||||
staged, err := stagedPins(ctx, dir)
|
staged, err := stagedPins(ctx, dir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
unknown()
|
unknown()
|
||||||
}
|
}
|
||||||
var links []gitlink
|
var links []gitlink
|
||||||
switch {
|
switch {
|
||||||
case !moves:
|
case !moves && keep:
|
||||||
links, err = indexGitlinks(ctx, dir)
|
links, err = indexGitlinks(ctx, dir)
|
||||||
|
case !moves:
|
||||||
|
own.StagedPins = len(staged)
|
||||||
|
links, err = treeGitlinks(ctx, dir, "HEAD")
|
||||||
case target != "":
|
case target != "":
|
||||||
own.StagedPins = len(staged)
|
own.StagedPins = len(staged)
|
||||||
links, err = treeGitlinks(ctx, dir, target)
|
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)
|
wg.Add(1)
|
||||||
go func() {
|
go func() {
|
||||||
defer wg.Done()
|
defer wg.Done()
|
||||||
results[i] = p.lossOf(ctx, sub, targets[c.path])
|
results[i] = p.lossOf(ctx, sub, targets[c.path], keep)
|
||||||
}()
|
}()
|
||||||
}
|
}
|
||||||
wg.Wait()
|
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) {
|
func TestRestoreLossCountsStagedPinsOfAMovingRepository(t *testing.T) {
|
||||||
p, root := ownFixture(t)
|
p, root := ownFixture(t)
|
||||||
super := filepath.Join(root, "super")
|
super := filepath.Join(root, "super")
|
||||||
|
|||||||
@@ -85,7 +85,7 @@ func (p *Provider) SwitchLoss(ctx context.Context, path string, b submodule.Bran
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return submodule.Loss{}, err
|
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
|
// 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
|
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
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -72,15 +72,24 @@ type Switcher interface {
|
|||||||
Switch(ctx context.Context, path string, b Branch) (string, error)
|
Switch(ctx context.Context, path string, b Branch) (string, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
// RestoreTarget is the pin a submodule is restored to. It doesn't matter for
|
// RestoreTarget is the pin a submodule is restored to, and whether staged
|
||||||
// the superproject, whose target is always HEAD.
|
// pins below it are kept. The superproject's target is always HEAD; only
|
||||||
|
// RestoreHard matters for it.
|
||||||
type RestoreTarget int
|
type RestoreTarget int
|
||||||
|
|
||||||
const (
|
const (
|
||||||
RestoreStaged RestoreTarget = iota // the pin in the parent's index: a staged pin is kept and used
|
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
|
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.
|
// Loss is what a restore discards. The zero Loss means nothing is lost.
|
||||||
type Loss struct {
|
type Loss struct {
|
||||||
Files int // changed, conflicted and untracked files and directories
|
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()
|
m.layout()
|
||||||
return m, m.filter.Focus()
|
return m, m.filter.Focus()
|
||||||
case key.Matches(msg, k.Restore):
|
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):
|
case key.Matches(msg, k.Switch):
|
||||||
return m.startSwitch()
|
return m.startSwitch()
|
||||||
case key.Matches(msg, k.Update):
|
case key.Matches(msg, k.Update):
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ type keyMap struct {
|
|||||||
Home, End, PageUp, PageDown key.Binding
|
Home, End, PageUp, PageDown key.Binding
|
||||||
Fetch, Reload, Filter, Issues key.Binding
|
Fetch, Reload, Filter, Issues key.Binding
|
||||||
Copy, Restore, Switch, Update key.Binding
|
Copy, Restore, Switch, Update key.Binding
|
||||||
|
HardRestore key.Binding
|
||||||
Help, Quit, QuitUpdate key.Binding
|
Help, Quit, QuitUpdate key.Binding
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -31,6 +32,7 @@ func defaultKeys() keyMap {
|
|||||||
Filter: key.NewBinding(key.WithKeys("/"), key.WithHelp("/", "filter")),
|
Filter: key.NewBinding(key.WithKeys("/"), key.WithHelp("/", "filter")),
|
||||||
Issues: key.NewBinding(key.WithKeys("!"), key.WithHelp("!", "problems only")),
|
Issues: key.NewBinding(key.WithKeys("!"), key.WithHelp("!", "problems only")),
|
||||||
Restore: key.NewBinding(key.WithKeys("x"), key.WithHelp("x", "restore")),
|
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")),
|
Switch: key.NewBinding(key.WithKeys("b"), key.WithHelp("b", "switch branch")),
|
||||||
Update: key.NewBinding(key.WithKeys("u"), key.WithHelp("u", "update pin")),
|
Update: key.NewBinding(key.WithKeys("u"), key.WithHelp("u", "update pin")),
|
||||||
Copy: key.NewBinding(key.WithKeys("c"), key.WithHelp("c", "copy path")),
|
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
|
// 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.
|
// are shown in the detail pane, or in the footer if the pane can't show them.
|
||||||
func (k keyMap) Submodule() []key.Binding {
|
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.
|
// 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.Up, k.Down, k.Left, k.Right, k.Toggle},
|
||||||
{k.CollapseAll, k.ExpandAll},
|
{k.CollapseAll, k.ExpandAll},
|
||||||
{k.Home, k.End, k.PageUp, k.PageDown},
|
{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},
|
{k.Fetch, k.Reload, k.Filter, k.Issues},
|
||||||
append([]key.Binding{k.Help, k.Quit}, k.fullUpdate()...),
|
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
|
// 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
|
// would be lost it restores at once, else it asks first. A submodule with a
|
||||||
// staged pin first asks which pin to restore to.
|
// staged pin first asks which pin to restore to; a hard restore doesn't: it
|
||||||
func (m Model) startRestore() (tea.Model, tea.Cmd) {
|
// goes to the committed pins, at every depth.
|
||||||
|
func (m Model) startRestore(hard bool) (tea.Model, tea.Cmd) {
|
||||||
s := m.tree.Selected()
|
s := m.tree.Selected()
|
||||||
_, ok := m.provider.(submodule.Restorer)
|
_, ok := m.provider.(submodule.Restorer)
|
||||||
var flash string
|
var flash string
|
||||||
@@ -67,6 +68,10 @@ func (m Model) startRestore() (tea.Model, tea.Cmd) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
m.restore = &restoreState{path: s.Path, name: restoreName(s)}
|
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 {
|
if !s.IsRoot() && s.Status != nil && s.Status.PinStaged {
|
||||||
m.restore.phase = restoreChoosing
|
m.restore.phase = restoreChoosing
|
||||||
m.layout()
|
m.layout()
|
||||||
@@ -130,7 +135,13 @@ func (m Model) lossChecked(msg lossMsg) (tea.Model, tea.Cmd) {
|
|||||||
m.restore.branch = msg.branch
|
m.restore.branch = msg.branch
|
||||||
}
|
}
|
||||||
m.restore.loss = msg.loss
|
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()
|
return m.runRestore()
|
||||||
}
|
}
|
||||||
m.restore.phase = restoreAsking
|
m.restore.phase = restoreAsking
|
||||||
@@ -199,13 +210,16 @@ func (m Model) restoreDone(msg restoreDoneMsg) (tea.Model, tea.Cmd) {
|
|||||||
return m, tea.Batch(cmds...)
|
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
|
// toText is " to origin/feat" for a switch, " to the committed pin" if that
|
||||||
// usual target and needs no words.
|
// 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 {
|
func (r *restoreState) toText() string {
|
||||||
if r.branch != nil {
|
switch {
|
||||||
|
case r.branch != nil:
|
||||||
return " to " + r.branch.Name
|
return " to " + r.branch.Name
|
||||||
}
|
case r.to == submodule.RestoreHard:
|
||||||
if r.to == submodule.RestoreCommitted {
|
return " to the committed pins"
|
||||||
|
case r.to == submodule.RestoreCommitted:
|
||||||
return " to the committed pin"
|
return " to the committed pin"
|
||||||
}
|
}
|
||||||
return ""
|
return ""
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package tui
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"os"
|
"os"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
tea "github.com/charmbracelet/bubbletea"
|
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)
|
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