Add restore (x): make a repository exactly what git records

Restores the selection and all submodules below it recursively, asking
only if work would be lost. May clone/fetch missing objects (D23).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-22 12:38:29 +02:00
co-authored by Claude Opus 5.5
parent a328d3cc3a
commit 7980d3356a
16 changed files with 1260 additions and 118 deletions
+3 -2
View File
@@ -37,8 +37,9 @@ gofmt -l . # must print nothing
state means (`Status.Issues()`). `internal/tui/theme` decides how it looks. state means (`Status.Issues()`). `internal/tui/theme` decides how it looks.
Components never classify states or define colors themselves. Components never classify states or define colors themselves.
- **Data access** only through `submodule.Provider` (and its optional - **Data access** only through `submodule.Provider` (and its optional
`Fetcher` / `Watcher`). The git provider only uses the network in `Fetcher` / `Watcher` / `Restorer`). The git provider only uses the
`Fetch`, and git must never prompt. network in `Fetch` and, for missing objects, in `Restore`; git must never
prompt, and the provider writes only on a user's action.
- **Colors** only from `theme.Theme`, ANSI 16 colors for meaning (see - **Colors** only from `theme.Theme`, ANSI 16 colors for meaning (see
`docs/ux-guide.md#colors`). Render segments on a background with a `base` `docs/ux-guide.md#colors`). Render segments on a background with a `base`
style (`LevelStyle(level, base)`, `Delta(pin, base)`). style (`LevelStyle(level, base)`, `Delta(pin, base)`).
+4 -2
View File
@@ -111,6 +111,7 @@ files.
| `/` | Filter | | `/` | Filter |
| `!` | Problems only | | `!` | Problems only |
| `c` | Copy path | | `c` | Copy path |
| `x` | Restore: make the worktree exactly what git records (asks if work would be lost) |
| `f` / `r` | Fetch / reload | | `f` / `r` | Fetch / reload |
| `?` | Full help | | `?` | Full help |
| `q` | Quit | | `q` | Quit |
@@ -119,8 +120,9 @@ The mouse works too: click to select, click `▸` to toggle, wheel to move.
## Status ## Status
A prototype. Browsing is complete and read-only; write actions (restore, A prototype. Browsing is complete, and `x` restores a submodule or the whole
stage pin) are being specified in [docs/actions.md](docs/actions.md). superproject. More write actions (stage pin) are being specified in
[docs/actions.md](docs/actions.md).
## Documentation ## Documentation
+3 -3
View File
@@ -33,10 +33,10 @@ 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. It is read-only so far; the first write actions submodules with problems. `x` restores the selection
are [drafted](actions.md). ([actions.md](actions.md)); stage pin is drafted there.
Next steps, roughly in order: Next steps, roughly in order:
1. Write actions: restore and stage pin, see [actions.md](actions.md). 1. Stage pin, see [actions.md](actions.md). (Restore is done.)
2. (done) "Problems only" (`!`) view, reusing the tree's filter. 2. (done) "Problems only" (`!`) view, reusing the tree's filter.
+89 -94
View File
@@ -1,10 +1,8 @@
# Actions (draft) # Actions (draft)
**Status: draft, not implemented.** The spec for the first two write actions. **Status:** [Restore](#restore) is implemented (key `x`, decisions in
Open questions are listed [at the end](#open-questions). Once they are [D23](decisions.md)). [Stage pin](#stage-pin) is still a draft; its open
answered, the decisions go into [decisions.md](decisions.md), and questions are [at the end](#open-questions).
[requirements.md](requirements.md) and [ux-guide.md](ux-guide.md) are
updated.
## Principle ## Principle
@@ -12,9 +10,9 @@ Offer as few actions as possible, but enough to work with submodules.
submarine shows what differs from what git records. The actions resolve such submarine shows what differs from what git records. The actions resolve such
a difference in one of two directions: a difference in one of two directions:
| Action | Direction | Key (proposed) | | Action | Direction | Key |
|---|---|---| |---|---|---|
| [Restore](#restore) | The worktree follows the recorded state | `u` | | [Restore](#restore) | The worktree follows the recorded state | `x` |
| [Stage pin](#stage-pin) | The recorded state follows the worktree | `s` | | [Stage pin](#stage-pin) | The recorded state follows the worktree | `s` |
There is nothing else for now. Committing, pushing, switching branches and There is nothing else for now. Committing, pushing, switching branches and
@@ -39,7 +37,7 @@ current root commit specifies.
Restoring the superproject resets its index too, so **staged pins are Restoring the superproject resets its index too, so **staged pins are
discarded**: afterwards every pin is the one in `HEAD`. Restoring a submodule discarded**: afterwards every pin is the one in `HEAD`. Restoring a submodule
does not touch the parent's index, so a staged pin of that submodule stays does not touch the parent's index, so a staged pin of that submodule stays
and is the target. (See open question 2.) and is the target.
### Postconditions ### Postconditions
@@ -51,7 +49,7 @@ After a successful restore of repository R with target T:
revert. revert.
3. **No untracked files or directories** remain in R. This includes leftover 3. **No untracked files or directories** remain in R. This includes leftover
directories of submodules that T no longer has (*untracked repository*). directories of submodules that T no longer has (*untracked repository*).
Ignored files are kept (see open question 1). Ignored files are kept.
4. **Every submodule that T records is present and initialized**, even if 4. **Every submodule that T records is present and initialized**, even if
it was never initialized, is inactive (`submodule.<name>.active`) or has it was never initialized, is inactive (`submodule.<name>.active`) or has
`update = none / merge / rebase` configured. Its URL comes from T's `update = none / merge / rebase` configured. Its URL comes from T's
@@ -94,75 +92,77 @@ absorbed submodule git directories stay.
### Confirmation ### Confirmation
- **Nothing would be lost** (only `off pin`, `missing`, a staged pin of the Restore asks **only if something would be lost**. Never asking is unsafe
root): restore runs at once, without a question. (`git clean -ff` deletes repositories that exist nowhere else, and nothing
- **Something would be lost:** a confirmation line opens above the footer, undoes a restore); always asking puts a question on the harmless, common
like the filter line. It says what is discarded, summed over the subtree: cases (`off pin`, `missing`, a staged pin) and teaches a reflexive `y`.
- **Nothing would be lost:** restore runs at once.
- **Something would be lost:** a line above the footer says what is
discarded, summed over the whole subtree on disk:
``` ```
Restore src/app? Discards 7 changed files in 3 submodules, 2 detached commits, 1 repository. y restore · n cancel Restore src/app? Discards 7 changed files in 3 repositories, 2 commits on no branch, 1 untracked repository. y restore · n cancel
``` ```
`y` runs it. `n`, `esc` or any other key cancels. `y` runs it, any other key cancels.
- **The state is not fully known** (a status in the subtree is still - **The state can't be read** for a repository: it is counted as `n
loading or failed to load): the line says so (`… · 2 submodules not repositories of unknown state` and needs the same `y`.
loaded`) and needs the same `y`. Restore does not wait for the loading.
- Lost repositories and commits are shown in the Danger color, files in
Warn.
The summary only covers the submodules that are loaded. Children of a The check reads the disk at the moment of the keypress (`RestoreLoss`), not
submodule that is not initialized are unknown. They are created fresh, so the loaded statuses, so loading or stale statuses don't matter. Nothing is
nothing of them can be lost. lost that is not in the table above; ignored files are never touched.
### Network ### Network
A restore may need objects that are not on disk: A restore may need objects that are not on disk: a submodule that was never
cloned (no git directory in `.git/modules`), or a target commit that was
- a submodule that was never cloned (`not initialized`, no git directory in never fetched. **Restore may clone and fetch, but only what is missing**,
`.git/modules`); non-interactively (`GIT_TERMINAL_PROMPT=0`, ssh `BatchMode`) and with a
- a target commit that was never fetched. timeout. If the commit is on no remote, restore fails with `commit … is not
available on any remote`. This is the one place besides `f` that uses the
Proposal: **restore may clone and fetch, but only for objects that are network (decision D23).
missing**, and never prompts (`GIT_TERMINAL_PROMPT=0`, as for `Fetch`).
This changes the rule "the provider uses the network only in `Fetch`"
(AGENTS.md, N8). The alternative is to fail with "pinned commit … not
fetched" and leave cloning to the user (open question 3).
### Running, progress and errors ### Running, progress and errors
- The selected row (or the header) shows a spinner and `restoring` in the - A line above the footer shows `checking …` and then `restoring …` with a
status column until the restore ends. The header shows `restoring src/app` spinner. Only one restore runs at a time; pressing `x` meanwhile flashes
where the fetch progress usually is. `wait for the restore of src/app to finish`. Everything else keeps working.
- Only one write action runs at a time. Pressing `u` or `s` meanwhile - A running fetch does not block a restore.
flashes `wait for the restore of src/app to finish`. Navigation and - Done: flash `restored src/app`; the app reloads the tree and all statuses,
everything else keep working. because the structure can have changed.
- A running background fetch does not block a restore. - Failure: the restore stops with git's message, shown in red above the
- Done: flash `restored src/app`. The watcher refreshes the affected rows. footer (`restore of src/app failed: …`) until the next key. Restore is
The app also reloads the subtree, because the structure can have changed. idempotent, so it can be run again after fixing the cause. Repositories
- Failure: the restore stops at the first repository that fails. The footer that were restored before the failure stay restored.
flashes `restore failed in src/app/lib: <git's message>`, and the detail
pane of the selected node shows the same message until the next restore.
Restore is idempotent, so it can be run again after fixing the cause.
### Implementation note ### Implementation note
For each repository, top-down (`T` = target): `git.Provider.Restore` (`internal/submodule/git/restore.go`) walks the
repositories itself, top-down; the children of a repository run
concurrently, bounded by the provider's semaphore. For each repository
(`T` = target) it runs, with `-c submodule.recurse=false`:
```sh ```sh
git merge --abort / rebase --abort / cherry-pick --abort / revert --abort # whichever is in progress cat-file / fetch --all # only if T is missing locally
git checkout --detach --force T # submodule off the pin; superproject: git reset --hard HEAD rebase|cherry-pick|revert --quit # whichever is in progress; --quit, not --abort: no ref moves
git reset --hard T checkout --force --detach T # only if HEAD is not T, so a branch is never moved
git clean -ffd # -ff: also untracked nested repositories reset --hard # also ends a merge
git submodule sync clean -ffd # -ff: untracked repositories too; ignored files stay
git submodule init # all submodules of T, including inactive ones submodule sync
# for each gitlink in T: make sure the repo exists (clone/absorb), then recurse submodule update --init --checkout --force -- <not yet initialized children>
# then every gitlink of T's tree (ls-tree -r T) is restored the same way
``` ```
The repository can be walked by submarine itself, not by `git submodule `--checkout` overrides `submodule.<name>.update`, and the explicit paths
update --recursive`, so progress, the loss check and errors are per override `submodule.<name>.active`, so no child module is skipped.
repository. `git submodule update --init --checkout --force` is an option for
the clone step. The loss check runs right before the restore, against the `RestoreLoss` (`lossOf`) follows the repositories on disk: per repository
current state, not the displayed one. `status` (without submodules), the in-progress state files, and
`rev-list --count HEAD --not --branches --tags --remotes` if HEAD is not the
target. A directory in the untracked list is searched for `.git`
*directories* (a standalone repository); an absorbed submodule leaves only a
`.git` file and loses just its worktree.
## Stage pin ## Stage pin
@@ -217,46 +217,41 @@ failure.
## Keys and palette ## Keys and palette
Both are submodule keys (`keyMap.Submodule`), shown in the detail pane's Both are submodule keys (`keyMap.Submodule`), shown in the detail pane's
palette, or in the footer, **only while they apply** to the selection: palette, or in the footer when the pane can't show it. Restore is always
offered: it is idempotent, and the palette is static.
| Key | Shown when | Pressed when not applicable (flash) | | Key | Action | Pressed when not possible (flash) |
|---|---|---| |---|---|---|
| `u` restore | The node or a loaded descendant has an issue restore fixes (`changed`, `conflict`, `missing`, `off pin`), or the root has a staged pin | `nothing to restore in src/app` | | `x` restore | Restore the selection | `this data source can't restore` (mock); `wait for the restore of … to finish` |
| `s` stage pin | [Available](#available-when) | `src/app is on its pin` / `src/app has uncommitted changes; commit or restore them first` / `the superproject has no pin` | | `s` stage pin | [Stage pin](#stage-pin), not implemented | `src/app is on its pin` / `src/app has uncommitted changes; commit or restore them first` / `the superproject has no pin` |
Full help (`?`) always lists both. `R` would sit next to `r` (reload), a dangerous mix-up for a destructive
action, hence `x`.
## Testing ## Testing
- Provider: tests on fixtures from `scripts/make-fixture.sh`, which builds - Provider (`restore_test.go`), on fixtures from `scripts/make-fixture.sh`:
every state. For restore: changed, untracked, untracked repository a restored superproject is exact (every repository clean, `submodule status
(absorbed and standalone), conflict, rebase in progress, off pin on a --recursive` without prefixes, `libs/missing` initialized), branches are
branch, off pin detached with unreachable commits, not initialized, not moved, ignored files survive, a merge and a rebase in progress end, a
inactive, `update = none`, a submodule added and one removed between two standalone clone and a removed submodule's directory are deleted, a missing
root commits, nested at depth ≥ 2. Assert the postconditions with submodule is cloned again, `update = none` and inactive submodules are
`git status --porcelain --ignored=no` and `git submodule status restored, an unavailable commit is reported. The loss is empty for off pin,
--recursive`, and that branches are unchanged. For stage pin: index entry, missing and unpushed submodules, counts files, nested changes, merges,
refusal on a dirty worktree, refusal when HEAD moved. detached commits (not those on a branch) and standalone repositories.
- Loss summary: unit tests on `submodule` (no UI), like `Status.Issues()`. - UI (`internal/tui/restore_test.go`): restores at once without a loss, asks
- UI: run it on a fixture in tmux (see with one (`y` restores, anything else cancels), the mock can't restore.
[architecture.md](architecture.md#testing)). Check the confirmation line, - By hand in tmux on a fixture, see
the spinner, the flash messages and the palette. [architecture.md](architecture.md#testing).
## Open questions ## Open questions
1. **Ignored files.** Should restore also delete ignored files (`git clean Decided for restore: ignored files are kept (`clean` without `-x`); a
-x`)? That is the literal "no additional files", but it deletes `.env`, submodule is restored to the index gitlink, so a staged pin is its target and
`node_modules`, build output and IDE settings in every submodule. survives; restore may clone and fetch what is missing; the key is `x`; it
*Proposal: keep ignored files.* asks only if something would be lost.
2. **Target of a submodule restore with a staged pin.** The staged pin
(index, what the UI shows as *Pinned*, what `git submodule update` does) Open (stage pin):
or the committed one (parent's `HEAD`, which also unstages)?
*Proposal: the index. Unstaging happens only through a superproject 1. **Key.** `s`?
restore.* 2. **Palette.** Show it always (like restore), or only while it applies?
3. **Network.** May restore clone uninitialized submodules and fetch missing
pins (non-interactive), or should it fail and ask for `f` / a manual
clone? *Proposal: allow it, only for missing objects.*
4. **Keys.** `u` restore, `s` stage pin? `R` would sit next to `r` reload,
which is a dangerous mix-up for a destructive action.
5. **Confirmation without loss.** Is "no question when nothing is lost" OK,
or should restore always ask?
+7
View File
@@ -64,6 +64,13 @@ type Watcher interface {
} }
``` ```
```go
type Restorer interface {
RestoreLoss(ctx context.Context, path string) (Loss, error) // what a restore would discard
Restore(ctx context.Context, path string) error // may clone/fetch what is missing
}
```
```go ```go
type Locator interface { type Locator interface {
Dir(path string) string // absolute directory, "" if unknown Dir(path string) string // absolute directory, "" if unknown
+27
View File
@@ -313,3 +313,30 @@ side effect of switching off the update check; that has
fresh as the last fetch. `fetched never` appears for a repository that was fresh as the last fetch. `fetched never` appears for a repository that was
never fetched. The progress display and per-submodule fetch errors stay as never fetched. The progress display and per-submodule fetch errors stay as
they are, they now belong to `f`. they are, they now belong to `f`.
## D23 — Restore: exact worktree, asks only for losses, may fetch what is missing
**Context.** The first write action had to give "the exact version of the
project my root commit specifies" in one key, without destroying work by
accident.
**Decision.** `x` restores the selection: reset to the target (HEAD for the
superproject, the index gitlink for a submodule, so a staged pin is kept and
used), remove untracked files and repositories, end merges/rebases, and do
the same for every submodule of the target tree, initializing missing ones.
- *Ignored files stay* (`clean` without `-x`): `.env`, `node_modules` and
IDE settings are not "additional files" worth deleting in every submodule.
- *Branches never move.* A checkout that is not on the target is detached
first, so commits stay on their branch.
- *It asks only if something is lost* (changed or untracked files,
operations in progress, commits on no ref, standalone repositories).
Never asking would delete repositories that exist nowhere else; always
asking would train a reflexive `y` on the harmless cases. The loss is read
from the disk when the key is pressed, not from the loaded statuses.
- *Network.* Supersedes the "network only in `Fetch`" rule (N8): restore may
clone a missing submodule and fetch a missing commit, non-interactively and
with a timeout, because "no child module missing" cannot be met offline.
- Key `x`, not `R`, which sits next to `r` (reload).
**Consequences.** `Restorer` is a new optional provider interface; the mock
does not implement it. Restore is idempotent and can be repeated after a
failure. It takes the time of its slowest clone; there is no per-repository
progress yet.
+13 -7
View File
@@ -128,10 +128,14 @@ commit hashes. It doesn't answer the questions users actually have:
list is not mistaken for a healthy repository. See list is not mistaken for a healthy repository. See
[Problems only](ux-guide.md#problems-only). [Problems only](ux-guide.md#problems-only).
### Actions (planned) ### Actions
The tool is read-only so far. The first write actions, restore and stage - **F56** `x` restores the selection (a submodule, or the superproject when
pin, are drafted in [actions.md](actions.md). the header is selected): its worktree becomes exactly what git records, and
so do all submodules below it, recursively. It asks first, but only if
something would be lost. See [actions.md](actions.md#restore).
Stage pin is drafted in [actions.md](actions.md#stage-pin).
## Usage ## Usage
@@ -154,7 +158,9 @@ submarine --mock # built-in mock data
- **N6** Works in narrow terminals (detail pane hides below 90 columns). - **N6** Works in narrow terminals (detail pane hides below 90 columns).
- **N7** The UI is independent of the data source: the mock and the git - **N7** The UI is independent of the data source: the mock and the git
provider are interchangeable without UI changes. provider are interchangeable without UI changes.
- **N8** The git provider only uses the network in `Fetch`, and never - **N8** The git provider only uses the network in `Fetch` and, for objects
prompts (no password, passphrase or host-key questions; those fetches that are missing, in `Restore`. It never prompts (no password, passphrase
fail with git's message). It uses the `git` command line, so the user's or host-key questions; those fail with git's message). It uses the `git`
git configuration applies. Apart from fetching, it is read-only. command line, so the user's git configuration applies. Apart from fetching
and the actions of [actions.md](actions.md), it is read-only: it writes
only when the user asks.
+20
View File
@@ -221,6 +221,7 @@ colors in components.
| `c` | Copy the absolute directory of the selected submodule to the clipboard (OSC 52, so it also works over SSH; needs terminal support, in tmux `set -g set-clipboard on`). The footer confirms with `copied <path>`. Without a local provider (mock) the path relative to the superproject is copied | | `c` | Copy the absolute directory of the selected submodule to the clipboard (OSC 52, so it also works over SSH; needs terminal support, in tmux `set -g set-clipboard on`). The footer confirms with `copied <path>`. Without a local provider (mock) the path relative to the superproject is copied |
| `/` | 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) |
"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).
@@ -273,6 +274,24 @@ the term*.
previous one. The superproject stays selectable. previous one. The superproject stays selectable.
- The header counts are not filtered. - The header counts are not filtered.
### Restore
`x` makes the selection, and everything below it, exactly what git records
(see [actions.md](actions.md#restore)). It uses the line above the footer:
| Phase | Line |
|---|---|
| reading what is lost | spinner, muted `checking src/app…` |
| something would be lost | yellow `Restore src/app?`, red `Discards 7 changed files in 3 repositories, 2 commits on no branch, 1 untracked repository.`, right-aligned `y restore · n cancel` |
| running | spinner, yellow `restoring src/app…` |
| 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 the
question is open. 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.
### Key reference ### Key reference
Every key in the UI, where it is shown. Update this Every key in the UI, where it is shown. Update this
@@ -290,6 +309,7 @@ where the palette can't be shown, see [Key bar and palette](#key-bar-and-palette
| `home` / `g`, `end` / `G` | Header / last submodule | Navigation | Help | | `home` / `g`, `end` / `G` | Header / last submodule | Navigation | Help |
| `pgup`, `pgdn` | Move by one screen | Navigation | Help | | `pgup`, `pgdn` | Move by one screen | Navigation | Help |
| `c` | Copy path | Submodule | Palette, Help | | `c` | Copy path | Submodule | Palette, Help |
| `x` | 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 |
+4 -2
View File
@@ -1,5 +1,6 @@
// Package git provides a read-only submodule.Provider backed by the git // Package git provides a submodule.Provider backed by the git command line.
// command line. It never touches the network. // It reads; it writes only in Restore, and uses the network only in Fetch and
// in Restore (for what is missing).
package git package git
import ( import (
@@ -32,6 +33,7 @@ type Provider struct {
sem chan struct{} // bounds concurrent Status calls sem chan struct{} // bounds concurrent Status calls
fetchSem chan struct{} // bounds concurrent fetches fetchSem chan struct{} // bounds concurrent fetches
sshEnv []string // extra environment for fetches sshEnv []string // extra environment for fetches
extraEnv []string // more environment for fetches and clones; tests
// WatchInterval is how often Watch checks for changes. // WatchInterval is how often Watch checks for changes.
WatchInterval time.Duration WatchInterval time.Duration
+400
View File
@@ -0,0 +1,400 @@
package git
import (
"context"
"errors"
"fmt"
"io/fs"
"os"
"path/filepath"
"strings"
"sync"
"time"
"submarine/internal/submodule"
)
// restoreArgs are prepended to every git command of a restore: the user's
// submodule.recurse must not make checkout and reset recurse behind our back,
// the restore walks the submodules itself.
var restoreArgs = []string{"-c", "submodule.recurse=false"}
// maxWalk bounds the entries visited when looking for repositories inside an
// untracked directory.
const maxWalk = 20000
// gitlink is a submodule entry of a tree or the index.
type gitlink struct{ path, sha string }
// RestoreLoss reports what Restore(path) would discard. It walks the
// repositories on disk, not the loaded tree.
func (p *Provider) RestoreLoss(ctx context.Context, path string) (submodule.Loss, error) {
dir, target, err := p.restoreTarget(ctx, path)
if err != nil {
return submodule.Loss{}, err
}
if dir == "" { // not initialized: nothing on disk to lose
return submodule.Loss{}, nil
}
return p.lossOf(ctx, dir, target), nil
}
// Restore makes path ("" is the superproject) and all submodules below it
// exactly what git records, see docs/actions.md. A missing submodule is
// cloned and missing commits are fetched; git never prompts.
func (p *Provider) Restore(ctx context.Context, path string) error {
if path == "" {
return p.restoreRepo(ctx, p.root, "HEAD")
}
p.mu.RLock()
in, ok := p.mods[path]
p.mu.RUnlock()
if !ok {
return fmt.Errorf("unknown submodule %q", path)
}
pin, err := indexGitlink(ctx, in)
if err != nil {
return err
}
if !initialized(in.dir) {
if err := p.initSubmodules(ctx, in.superDir, []string{in.relPath}); err != nil {
return fmt.Errorf("%s: %w", path, err)
}
}
return p.restoreRepo(ctx, in.dir, pin)
}
// restoreTarget returns the directory and the commit path is restored to:
// HEAD for the superproject, else the gitlink in its parent's index. The
// directory is "" if the submodule is not initialized.
func (p *Provider) restoreTarget(ctx context.Context, path string) (dir, target string, err error) {
if path == "" {
return p.root, "HEAD", nil
}
p.mu.RLock()
in, ok := p.mods[path]
p.mu.RUnlock()
if !ok {
return "", "", fmt.Errorf("unknown submodule %q", path)
}
pin, err := indexGitlink(ctx, in)
if err != nil {
return "", "", err
}
if !initialized(in.dir) {
return "", pin, nil
}
return in.dir, pin, nil
}
func indexGitlink(ctx context.Context, in info) (string, error) {
out, err := run(ctx, in.superDir, "ls-files", "-s", "--", in.relPath)
if err != nil {
return "", err
}
pin := parseGitlink(out)
if pin == "" {
return "", errors.New("has no gitlink in the superproject index (stale .gitmodules entry?)")
}
return pin, nil
}
// --- restore -----------------------------------------------------------------
// restoreRepo restores the initialized repository at dir to target (a commit
// or "HEAD") and then every submodule that target records. Branches are never
// moved: a checkout that is not on target is detached first.
func (p *Provider) restoreRepo(ctx context.Context, dir, target string) error {
env := p.networkEnv()
g := func(args ...string) (string, error) {
return p.exec(ctx, dir, env, append(append([]string{}, restoreArgs...), args...)...)
}
sha, err := g("rev-parse", "--verify", "-q", target+"^{commit}")
if err != nil {
if sha, err = p.fetchMissing(ctx, dir, target); err != nil {
return fmt.Errorf("%s: %w", dir, err)
}
}
if gitDir, _, err := gitDirs(dir); err == nil {
p.clearOperations(ctx, dir, gitDir)
}
if head, err := g("rev-parse", "--verify", "-q", "HEAD"); err != nil || head != sha {
if _, err := g("checkout", "--force", "--detach", sha); err != nil {
return err
}
}
if _, err := g("reset", "--hard", "-q"); err != nil {
return err
}
// -ff also removes untracked repositories; ignored files stay
if _, err := g("clean", "-ffdq"); err != nil {
return err
}
if _, err := g("submodule", "sync", "-q"); err != nil {
return err
}
links, err := treeGitlinks(ctx, dir, sha)
if err != nil {
return err
}
var missing []string
for _, l := range links {
if !initialized(filepath.Join(dir, filepath.FromSlash(l.path))) {
missing = append(missing, l.path)
}
}
if len(missing) > 0 {
if err := p.initSubmodules(ctx, dir, missing); err != nil {
return err
}
}
// the children are independent repositories
var wg sync.WaitGroup
errs := make([]error, len(links))
for i, l := range links {
wg.Add(1)
go func() {
defer wg.Done()
if err := p.restoreRepo(ctx, filepath.Join(dir, filepath.FromSlash(l.path)), l.sha); err != nil {
errs[i] = err
}
}()
}
wg.Wait()
return errors.Join(errs...)
}
// networkEnv is the environment of commands that may clone or fetch.
func (p *Provider) networkEnv() []string {
return append(append([]string{}, p.sshEnv...), p.extraEnv...)
}
// initSubmodules clones or checks out the given submodules of the repository
// at dir, whatever their active and update settings say (--checkout overrides
// update = none/rebase/merge, the explicit paths override inactive).
func (p *Provider) initSubmodules(ctx context.Context, dir string, rels []string) error {
ctx, cancel := context.WithTimeout(ctx, fetchTimeout*time.Duration(len(rels)))
defer cancel()
args := append(append([]string{}, restoreArgs...), "submodule", "update", "--init", "--checkout", "--force", "-q", "--")
_, err := p.exec(ctx, dir, p.networkEnv(), append(args, rels...)...)
return err
}
// exec runs git like runEnv, but counts against the concurrency limit. Hold
// it only for the command, never while recursing.
func (p *Provider) exec(ctx context.Context, dir string, env []string, args ...string) (string, error) {
select {
case p.sem <- struct{}{}:
defer func() { <-p.sem }()
case <-ctx.Done():
return "", ctx.Err()
}
return runEnv(ctx, dir, env, args...)
}
// fetchMissing fetches all remotes of dir to get target, and returns its SHA.
func (p *Provider) fetchMissing(ctx context.Context, dir, target string) (string, error) {
fctx, cancel := context.WithTimeout(ctx, fetchTimeout)
defer cancel()
// a failing fetch is reported with the missing commit below
_, ferr := runEnv(fctx, dir, p.networkEnv(), "fetch", "--all", "--no-recurse-submodules", "--quiet")
sha, ok, err := try(ctx, dir, "rev-parse", "--verify", "-q", target+"^{commit}")
if err != nil {
return "", err
}
if !ok {
msg := fmt.Sprintf("commit %.7s is not available", target)
if ferr != nil {
msg += " (fetch failed: " + firstLine(ferr) + ")"
} else {
msg += " on any remote"
}
return "", errors.New(msg)
}
return sha, nil
}
func firstLine(err error) string {
var ge *gitError
msg := err.Error()
if errors.As(err, &ge) && ge.Stderr != "" {
msg = ge.Stderr
}
line, _, _ := strings.Cut(msg, "\n")
return strings.TrimPrefix(line, "fatal: ")
}
// clearOperations ends a merge, rebase, cherry-pick or revert in progress
// without moving any ref (--quit, not --abort).
func (p *Provider) clearOperations(ctx context.Context, dir, gitDir string) {
exists := func(name string) bool { _, err := os.Stat(filepath.Join(gitDir, name)); return err == nil }
if exists("rebase-merge") || exists("rebase-apply") {
_, _, _ = try(ctx, dir, "rebase", "--quit")
}
if exists("CHERRY_PICK_HEAD") || exists("sequencer") {
_, _, _ = try(ctx, dir, "cherry-pick", "--quit")
}
if exists("REVERT_HEAD") {
_, _, _ = try(ctx, dir, "revert", "--quit")
}
// a merge is ended by the reset --hard that follows
}
// treeGitlinks lists the submodules recorded in commit's tree, at any depth
// of directories.
func treeGitlinks(ctx context.Context, dir, commit string) ([]gitlink, error) {
out, err := run(ctx, dir, "ls-tree", "-r", "-z", commit)
if err != nil {
return nil, err
}
var links []gitlink
for _, rec := range strings.Split(out, "\x00") {
meta, path, ok := strings.Cut(rec, "\t")
if f := strings.Fields(meta); ok && len(f) == 3 && f[0] == "160000" {
links = append(links, gitlink{path: path, sha: f[2]})
}
}
return links, nil
}
// indexGitlinks lists the submodules in the index of the repository at dir.
func indexGitlinks(ctx context.Context, dir string) ([]gitlink, error) {
out, err := run(ctx, dir, "ls-files", "-s", "-z")
if err != nil {
return nil, err
}
var links []gitlink
for _, rec := range strings.Split(out, "\x00") {
meta, path, ok := strings.Cut(rec, "\t")
if f := strings.Fields(meta); ok && len(f) == 3 && f[0] == "160000" && f[2] == "0" {
links = append(links, gitlink{path: path, sha: f[1]})
}
}
return links, nil
}
// --- loss --------------------------------------------------------------------
// 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 {
var own submodule.Loss
var children []gitlink
var targets map[string]string
unknown := func() { own.Unknown++ }
func() {
select {
case p.sem <- struct{}{}:
defer func() { <-p.sem }()
case <-ctx.Done():
unknown()
return
}
out, err := run(ctx, dir, "status", "--porcelain=v2", "-z", "--untracked-files=normal", "--ignore-submodules=all")
if err != nil {
unknown()
return
}
for _, e := range parseStatus(out) {
if e.submodule {
continue // restored without loss
}
own.Files++
if e.kind == submodule.ChangeUntracked && strings.HasSuffix(e.path, "/") {
own.Repositories += standaloneRepos(ctx, filepath.Join(dir, filepath.FromSlash(e.path)))
}
}
if gitDir, _, err := gitDirs(dir); err == nil {
for _, name := range []string{"rebase-merge", "rebase-apply", "MERGE_HEAD", "CHERRY_PICK_HEAD", "REVERT_HEAD", "sequencer"} {
if _, err := os.Stat(filepath.Join(gitDir, name)); err == nil {
own.Operations++
break
}
}
}
// commits only this checkout reaches are lost if the checkout moves
head, err := run(ctx, dir, "rev-parse", "--verify", "-q", "HEAD")
if err != nil {
unknown()
} else {
moves := target == ""
if !moves {
if sha, ok, _ := try(ctx, dir, "rev-parse", "--verify", "-q", target+"^{commit}"); ok {
moves = sha != head
} else {
moves = true
}
}
if moves {
n, ok, _ := try(ctx, dir, "rev-list", "--count", "HEAD", "--not", "--branches", "--tags", "--remotes")
if ok {
fmt.Sscan(n, &own.Commits)
} else {
unknown()
}
}
}
if children, err = indexGitlinks(ctx, dir); err != nil {
unknown()
}
if target != "" {
if links, err := treeGitlinks(ctx, dir, target); err == nil {
targets = map[string]string{}
for _, l := range links {
targets[l.path] = l.sha
}
}
}
}()
if own.Files+own.Operations+own.Commits+own.Repositories+own.Unknown > 0 {
own.Modules = 1
}
// the children on disk, concurrently
results := make([]submodule.Loss, len(children))
var wg sync.WaitGroup
for i, c := range children {
sub := filepath.Join(dir, filepath.FromSlash(c.path))
if !initialized(sub) {
continue
}
wg.Add(1)
go func() {
defer wg.Done()
results[i] = p.lossOf(ctx, sub, targets[c.path])
}()
}
wg.Wait()
for _, r := range results {
own = own.Add(r)
}
return own
}
// standaloneRepos counts the repositories with their own .git directory in
// the untracked directory dir (or dir itself). An absorbed submodule leaves
// only a .git file; its commits live on in .git/modules.
func standaloneRepos(ctx context.Context, dir string) int {
n, seen := 0, 0
_ = filepath.WalkDir(dir, func(path string, d fs.DirEntry, err error) error {
if seen++; err != nil || seen > maxWalk || ctx.Err() != nil {
return filepath.SkipAll
}
if d.IsDir() && d.Name() == ".git" {
n++
return filepath.SkipDir
}
return nil
})
return n
}
+261
View File
@@ -0,0 +1,261 @@
package git
import (
"context"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
"submarine/internal/submodule"
)
// out runs git and returns its trimmed stdout.
func out(t *testing.T, dir string, args ...string) string {
t.Helper()
b, err := exec.Command("git", append([]string{"-C", dir}, args...)...).Output()
if err != nil {
t.Fatalf("git %v: %v", args, err)
}
return strings.TrimSpace(string(b))
}
func exists(path string) bool { _, err := os.Stat(path); return err == nil }
// assertExact checks that the superproject at dir and every submodule below
// it is initialized, on its pin and has no change at all.
func assertExact(t *testing.T, dir string) {
t.Helper()
if s := out(t, dir, "status", "--porcelain"); s != "" {
t.Errorf("%s not clean:\n%s", dir, s)
}
raw, err := exec.Command("git", "-C", dir, "submodule", "status", "--recursive").Output() // the prefix is a leading space
if err != nil {
t.Fatal(err)
}
for _, line := range strings.Split(string(raw), "\n") {
if line == "" {
continue
}
if line[0] != ' ' {
t.Errorf("submodule not exact: %s", line)
continue
}
f := strings.Fields(line)
if s := out(t, filepath.Join(dir, f[1]), "status", "--porcelain"); s != "" {
t.Errorf("%s not clean:\n%s", f[1], s)
}
}
}
func TestRestoreSuperprojectMakesEverythingExact(t *testing.T) {
p, root := ownFixture(t)
super := filepath.Join(root, "super")
ctx := context.Background()
// ignored files stay
if err := os.WriteFile(filepath.Join(super, ".git", "info", "exclude"), []byte("keep.log\n"), 0o644); err != nil {
t.Fatal(err)
}
keep := filepath.Join(super, "libs", "work", "keep.log")
// libs/work is a submodule: its own exclude file lives in its git dir
wkGit := out(t, filepath.Join(super, "libs", "work"), "rev-parse", "--absolute-git-dir")
if err := os.WriteFile(filepath.Join(wkGit, "info", "exclude"), []byte("keep.log\n"), 0o644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(keep, []byte("x"), 0o644); err != nil {
t.Fatal(err)
}
// a branch must not move
conflict := filepath.Join(super, "libs", "conflict")
mainTip := out(t, conflict, "rev-parse", "main")
if err := p.Restore(ctx, ""); err != nil {
t.Fatal(err)
}
assertExact(t, super)
if got := out(t, conflict, "rev-parse", "main"); got != mainTip {
t.Errorf("branch main moved: %s -> %s", mainTip, got)
}
if !exists(keep) {
t.Error("ignored file was deleted")
}
if exists(filepath.Join(conflict, ".git", "MERGE_HEAD")) || exists(filepath.Join(out(t, conflict, "rev-parse", "--absolute-git-dir"), "MERGE_HEAD")) {
t.Error("merge still in progress")
}
if !exists(filepath.Join(super, "libs", "missing", ".git")) {
t.Error("libs/missing was not initialized")
}
if out(t, super, "diff", "--cached", "--name-only") != "" {
t.Error("staged pin survived a superproject restore")
}
}
func TestRestoreSubmoduleKeepsStagedPinAsTarget(t *testing.T) {
p, root := ownFixture(t)
super := filepath.Join(root, "super")
staged := out(t, filepath.Join(super, "libs", "staged"), "rev-parse", "HEAD")
// the fixture staged the checked-out commit; move the checkout away
git(t, filepath.Join(super, "libs", "staged"), "checkout", "-q", "--detach", "HEAD~1")
if err := p.Restore(context.Background(), "libs/staged"); err != nil {
t.Fatal(err)
}
if got := out(t, filepath.Join(super, "libs", "staged"), "rev-parse", "HEAD"); got != staged {
t.Errorf("HEAD = %s, want the staged pin %s", got, staged)
}
if !strings.Contains(out(t, super, "diff", "--cached", "--name-only"), "libs/staged") {
t.Error("restoring a submodule must keep its staged pin")
}
}
func TestRestoreLoss(t *testing.T) {
p, root := ownFixture(t)
super := filepath.Join(root, "super")
ctx := context.Background()
loss := func(path string) submodule.Loss {
t.Helper()
l, err := p.RestoreLoss(ctx, path)
if err != nil {
t.Fatal(err)
}
return l
}
// off pin and missing lose nothing; neither does an unpushed pin
for _, path := range []string{"src/core", "libs/missing", "src/net", "libs/staged"} {
if l := loss(path); !l.Empty() {
t.Errorf("%s: loss %+v, want none", path, l)
}
}
// libs/work: 6 files and a directory, untracked and changed
if l := loss("libs/work"); l.Files < 6 || l.Modules != 1 {
t.Errorf("libs/work: %+v", l)
}
// a nested change counts for its ancestors
if l := loss("libs/log"); l.Files != 1 || l.Modules != 1 {
t.Errorf("libs/log: %+v, want the nested change", l)
}
// a merge in progress
if l := loss("libs/conflict"); l.Operations != 1 || l.Files == 0 {
t.Errorf("libs/conflict: %+v", l)
}
// commits: detached and unreachable are lost, on a branch they are not
ui := filepath.Join(super, "src", "ui")
git(t, ui, "checkout", "-q", "--detach")
git(t, ui, "commit", "-q", "--allow-empty", "-m", "detached work")
if l := loss("src/ui"); l.Commits != 1 {
t.Errorf("detached commit: %+v", l)
}
git(t, ui, "switch", "-q", "-c", "wip")
if l := loss("src/ui"); l.Commits != 0 {
t.Errorf("commit on a branch: %+v", l)
}
// a clone nobody added as a submodule
git(t, super, "clone", "-q", filepath.Join(root, "remotes", "core.git"), "stray")
if l := loss(""); l.Repositories != 1 {
t.Errorf("root: %+v, want one standalone repository", l)
}
}
func TestRestoreRemovesLeftoversAndEndsRebase(t *testing.T) {
p, root := ownFixture(t)
super := filepath.Join(root, "super")
ctx := context.Background()
// a standalone clone is deleted
git(t, super, "clone", "-q", filepath.Join(root, "remotes", "core.git"), "stray")
// a submodule the branch no longer has leaves its directory behind
git(t, super, "rm", "-q", "--cached", "libs/topic")
git(t, super, "commit", "-q", "-m", "Drop topic")
// a rebase in progress in vendor; the branches must survive
v := filepath.Join(super, "vendor")
base := out(t, v, "rev-parse", "HEAD")
git(t, v, "switch", "-q", "-c", "x")
os.WriteFile(filepath.Join(v, "README"), []byte("x\n"), 0o644)
git(t, v, "commit", "-q", "-am", "x")
git(t, v, "switch", "-q", "-c", "y", base)
os.WriteFile(filepath.Join(v, "README"), []byte("y\n"), 0o644)
git(t, v, "commit", "-q", "-am", "y")
yTip := out(t, v, "rev-parse", "y")
_ = exec.Command("git", "-C", v, "rebase", "x").Run() // conflicts
if !exists(filepath.Join(out(t, v, "rev-parse", "--absolute-git-dir"), "rebase-merge")) {
t.Fatal("setup: no rebase in progress")
}
if err := p.Restore(ctx, ""); err != nil {
t.Fatal(err)
}
for _, gone := range []string{"stray", "libs/topic"} {
if exists(filepath.Join(super, gone)) {
t.Errorf("%s still exists", gone)
}
}
if exists(filepath.Join(out(t, v, "rev-parse", "--absolute-git-dir"), "rebase-merge")) {
t.Error("rebase still in progress")
}
if got := out(t, v, "rev-parse", "y"); got != yTip {
t.Errorf("branch y moved: %s -> %s", yTip, got)
}
if out(t, super, "status", "--porcelain") != "" {
t.Errorf("superproject not clean:\n%s", out(t, super, "status", "--porcelain"))
}
}
func TestRestoreClonesAMissingSubmodule(t *testing.T) {
p, root := ownFixture(t)
p.extraEnv = []string{"GIT_CONFIG_COUNT=1", "GIT_CONFIG_KEY_0=protocol.file.allow", "GIT_CONFIG_VALUE_0=always"}
super := filepath.Join(root, "super")
// no object database left: it must be cloned again
if err := os.RemoveAll(filepath.Join(super, ".git", "modules", "libs", "missing")); err != nil {
t.Fatal(err)
}
if err := p.Restore(context.Background(), "libs/missing"); err != nil {
t.Fatal(err)
}
if !exists(filepath.Join(super, "libs", "missing", ".git")) {
t.Error("not cloned")
}
if got, want := out(t, filepath.Join(super, "libs", "missing"), "rev-parse", "HEAD"), strings.Fields(out(t, super, "ls-files", "-s", "libs/missing"))[1]; got != want {
t.Errorf("HEAD = %s, want pin %s", got, want)
}
}
func TestRestoreReportsAnUnavailablePin(t *testing.T) {
p, root := ownFixture(t)
super := filepath.Join(root, "super")
// pin a commit that exists on no remote and not in the submodule
core := filepath.Join(super, "src", "core")
bogus := "0123456789abcdef0123456789abcdef01234567"
git(t, super, "update-index", "--cacheinfo", "160000,"+bogus+",src/core")
_ = core
err := p.Restore(context.Background(), "src/core")
if err == nil || !strings.Contains(err.Error(), "not available") {
t.Fatalf("err = %v, want a message about the unavailable commit", err)
}
}
func TestRestoreIgnoresUpdateNoneAndInactive(t *testing.T) {
p, root := ownFixture(t)
super := filepath.Join(root, "super")
git(t, super, "config", "submodule.libs/missing.update", "none")
git(t, super, "config", "submodule.libs/missing.active", "false")
git(t, super, "config", "submodule.active", "src")
if err := p.Restore(context.Background(), ""); err != nil {
t.Fatal(err)
}
if !exists(filepath.Join(super, "libs", "missing", ".git")) {
t.Error("libs/missing not initialized")
}
assertExact(t, super)
}
+36
View File
@@ -33,6 +33,42 @@ type Locator interface {
Dir(path string) string Dir(path string) string
} }
// Restorer is implemented by providers that can make a repository (and all
// submodules below it) exactly what git records. Path "" is the
// superproject. Restore may clone or fetch objects that are missing, but must
// never prompt the user.
type Restorer interface {
// RestoreLoss reports what Restore would discard, read from the disk at
// this moment.
RestoreLoss(ctx context.Context, path string) (Loss, error)
Restore(ctx context.Context, path string) error
}
// 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
Modules int // repositories with a loss, the repository itself included
Operations int // merges, rebases, cherry-picks, reverts in progress
Commits int // commits that no branch, tag or remote ref reaches
Repositories int // untracked repositories with their own .git directory
Unknown int // repositories whose state could not be read
}
// Empty reports whether nothing would be lost.
func (l Loss) Empty() bool { return l == Loss{} }
// Add sums two losses.
func (l Loss) Add(o Loss) Loss {
return Loss{
Files: l.Files + o.Files,
Modules: l.Modules + o.Modules,
Operations: l.Operations + o.Operations,
Commits: l.Commits + o.Commits,
Repositories: l.Repositories + o.Repositories,
Unknown: l.Unknown + o.Unknown,
}
}
// Change reports that the git metadata of a repository changed. // Change reports that the git metadata of a repository changed.
type Change struct { type Change struct {
Path string // "" is the superproject Path string // "" is the superproject
+30 -5
View File
@@ -56,6 +56,9 @@ type Model struct {
// exactly then. The input is focused while typing and blurred after enter. // exactly then. The input is focused while typing and blurred after enter.
filtering bool filtering bool
restore *restoreState // the restore in progress, nil if none
notice string // an error to show above the footer until the next key
width, height int width, height int
showDetail bool showDetail bool
} }
@@ -156,7 +159,7 @@ func (m *Model) startFetch() tea.Cmd {
// busy reports whether something shows a spinner. // busy reports whether something shows a spinner.
func (m Model) busy() bool { func (m Model) busy() bool {
if m.repo == nil || m.fetch.Running { if m.repo == nil || m.fetch.Running || (m.restore != nil && m.restore.phase != restoreAsking) {
return true return true
} }
loading := m.repo.Root != nil && m.repo.Root.Status == nil && m.repo.Root.Err == nil loading := m.repo.Root != nil && m.repo.Root.Status == nil && m.repo.Root.Err == nil
@@ -202,6 +205,12 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case changeMsg: case changeMsg:
return m.changed(msg) return m.changed(msg)
case lossMsg:
return m.lossChecked(msg)
case restoreDoneMsg:
return m.restoreDone(msg)
case fetchDoneMsg: case fetchDoneMsg:
m.fetch.Done++ m.fetch.Done++
if msg.err != nil { if msg.err != nil {
@@ -322,6 +331,13 @@ func (m Model) changed(msg changeMsg) (tea.Model, tea.Cmd) {
func (m Model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { func (m Model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
k := m.keys k := m.keys
if m.restore != nil && m.restore.phase == restoreAsking {
return m.answerRestore(msg)
}
if m.notice != "" {
m.notice = ""
m.layout()
}
if m.filter.Focused() { if m.filter.Focused() {
return m.typeFilter(msg) return m.typeFilter(msg)
} }
@@ -377,6 +393,8 @@ func (m Model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
m.filtering = true m.filtering = true
m.layout() m.layout()
return m, m.filter.Focus() return m, m.filter.Focus()
case key.Matches(msg, k.Restore):
return m.startRestore()
case key.Matches(msg, k.Issues): case key.Matches(msg, k.Issues):
m.tree.SetProblemsOnly(!m.tree.ProblemsOnly()) m.tree.SetProblemsOnly(!m.tree.ProblemsOnly())
m.layout() m.layout()
@@ -438,9 +456,12 @@ func (m Model) filterView() string {
return lipgloss.NewStyle().Width(m.width).Padding(0, 1).Render(in + strings.Repeat(" ", gap) + hint) return lipgloss.NewStyle().Width(m.width).Padding(0, 1).Render(in + strings.Repeat(" ", gap) + hint)
} }
// extraLine reports whether a line above the footer is shown: the filter, or // extraLine reports whether a line above the footer is shown: a restore in
// the notice that only problems are shown. // progress, a failure, the filter, or the notice that only problems are
func (m Model) extraLine() bool { return m.filtering || m.tree.ProblemsOnly() } // shown.
func (m Model) extraLine() bool {
return m.filtering || m.tree.ProblemsOnly() || m.restore != nil || m.notice != ""
}
// problemsView is the line above the footer while only problems are shown, so // problemsView is the line above the footer while only problems are shown, so
// a short list is not mistaken for a healthy repository. // a short list is not mistaken for a healthy repository.
@@ -512,7 +533,11 @@ func (m Model) View() string {
head := header.View(m.theme, m.repo, m.fetch, m.spinner.View(), m.width, m.tree.RootSelected()) head := header.View(m.theme, m.repo, m.fetch, m.spinner.View(), m.width, m.tree.RootSelected())
parts := []string{zone.Mark(m.tree.RootZone(), head), body} parts := []string{zone.Mark(m.tree.RootZone(), head), body}
if m.filtering { if m.restore != nil {
parts = append(parts, m.restoreView())
} else if m.notice != "" {
parts = append(parts, m.noticeView())
} else if m.filtering {
parts = append(parts, m.filterView()) parts = append(parts, m.filterView())
} else if m.tree.ProblemsOnly() { } else if m.tree.ProblemsOnly() {
parts = append(parts, m.problemsView()) parts = append(parts, m.problemsView())
+4 -3
View File
@@ -7,7 +7,7 @@ type keyMap struct {
CollapseAll, ExpandAll key.Binding CollapseAll, ExpandAll key.Binding
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, Help, Quit key.Binding Copy, Restore, Help, Quit key.Binding
} }
func defaultKeys() keyMap { func defaultKeys() keyMap {
@@ -27,6 +27,7 @@ func defaultKeys() keyMap {
Reload: key.NewBinding(key.WithKeys("r"), key.WithHelp("r", "reload")), Reload: key.NewBinding(key.WithKeys("r"), key.WithHelp("r", "reload")),
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")),
Copy: key.NewBinding(key.WithKeys("c"), key.WithHelp("c", "copy path")), Copy: key.NewBinding(key.WithKeys("c"), key.WithHelp("c", "copy path")),
Help: key.NewBinding(key.WithKeys("?"), key.WithHelp("?", "help")), Help: key.NewBinding(key.WithKeys("?"), key.WithHelp("?", "help")),
Quit: key.NewBinding(key.WithKeys("q", "ctrl+c"), key.WithHelp("q", "quit")), Quit: key.NewBinding(key.WithKeys("q", "ctrl+c"), key.WithHelp("q", "quit")),
@@ -43,7 +44,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 { return []key.Binding{k.Copy} } func (k keyMap) Submodule() []key.Binding { return []key.Binding{k.Copy, k.Restore} }
// Global returns the bindings that don't depend on the selection. // Global returns the bindings that don't depend on the selection.
func (k keyMap) Global() []key.Binding { return []key.Binding{k.Help, k.Quit} } func (k keyMap) Global() []key.Binding { return []key.Binding{k.Help, k.Quit} }
@@ -57,7 +58,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.Copy, k.Restore},
{k.Fetch, k.Reload, k.Filter, k.Issues}, {k.Fetch, k.Reload, k.Filter, k.Issues},
{k.Help, k.Quit}, {k.Help, k.Quit},
} }
+206
View File
@@ -0,0 +1,206 @@
package tui
import (
"fmt"
"strings"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/charmbracelet/x/ansi"
"submarine/internal/submodule"
"submarine/internal/tui/theme"
)
type restorePhase int
const (
restoreChecking restorePhase = iota // reading what would be lost
restoreAsking // waiting for y / n
restoreRunning
)
// restoreState is the restore in progress; only one runs at a time.
type restoreState struct {
path, name string // name is how the footer calls it
phase restorePhase
loss submodule.Loss
}
// lossMsg is the result of the check before a restore.
type lossMsg struct {
path string
loss submodule.Loss
err error
}
type restoreDoneMsg struct {
path string
err error
}
// startRestore checks what restoring the selection would discard. If nothing
// would be lost it restores at once, else it asks first.
func (m Model) startRestore() (tea.Model, tea.Cmd) {
s := m.tree.Selected()
r, ok := m.provider.(submodule.Restorer)
var flash string
switch {
case s == nil:
return m, nil
case !ok:
flash = "this data source can't restore"
case m.restore != nil:
flash = "wait for the restore of " + m.restore.name + " to finish"
}
if flash != "" {
cmd := m.footer.Flash(flash)
m.layout()
return m, cmd
}
m.restore = &restoreState{path: s.Path, name: restoreName(s)}
m.layout()
ctx, path := m.ctx, s.Path
check := func() tea.Msg {
loss, err := r.RestoreLoss(ctx, path)
return lossMsg{path, loss, err}
}
return m, tea.Batch(check, m.tick())
}
// 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 {
return m, nil
}
if msg.err != nil {
m.restore = nil
m.notice = "can't check " + restoreName(m.byPath[msg.path]) + ": " + msg.err.Error()
m.layout()
return m, nil
}
m.restore.loss = msg.loss
if msg.loss.Empty() {
return m.runRestore()
}
m.restore.phase = restoreAsking
m.layout()
return m, nil
}
// answerRestore handles a key while the restore waits for its answer: y
// restores, anything else cancels.
func (m Model) answerRestore(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
switch {
case msg.Type == tea.KeyCtrlC:
return m, tea.Quit
case msg.String() == "y":
return m.runRestore()
}
m.restore = nil
m.layout()
return m, nil
}
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)} }
return m, tea.Batch(run, m.tick())
}
// restoreDone shows the result and reloads everything: the structure may have
// changed.
func (m Model) restoreDone(msg restoreDoneMsg) (tea.Model, tea.Cmd) {
name := "the superproject"
if m.restore != nil {
name = m.restore.name
}
m.restore = nil
var cmds []tea.Cmd
if msg.err != nil {
m.notice = "restore of " + name + " failed: " + msg.err.Error()
} else {
cmds = append(cmds, m.footer.Flash("restored "+name))
}
cmds = append(cmds, m.reloadTree(true))
m.layout()
return m, tea.Batch(cmds...)
}
func restoreName(s *submodule.Submodule) string {
if s == nil || s.IsRoot() {
return "the superproject"
}
return s.Path
}
// restoreView is the line above the footer while a restore is in progress.
func (m Model) restoreView() string {
r := m.restore
left, right := "", ""
switch r.phase {
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+"…")
case restoreAsking:
left = theme.Fg(m.theme.Warn).Render("Restore "+r.name+"?") + " " +
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")
}
return m.line(left, right)
}
// line lays out one line above the footer: left, right-aligned right. The
// left part is cut if both don't fit.
func (m Model) line(left, right string) string {
w := m.width - 2
if right != "" {
left = ansi.Truncate(left, max(w-lipgloss.Width(right)-2, 1), "…")
}
gap := max(w-lipgloss.Width(left)-lipgloss.Width(right), 1)
return lipgloss.NewStyle().Width(m.width).Padding(0, 1).Render(left + strings.Repeat(" ", gap) + right)
}
// noticeView is the line above the footer after a restore failed.
func (m Model) noticeView() string {
return m.line(theme.Fg(m.theme.Danger).Render(m.notice), "")
}
// lossText words what a restore discards, e.g. "7 changed files in 3
// repositories, 2 commits on no branch, 1 untracked repository".
func lossText(l submodule.Loss) string {
var parts []string
if l.Files > 0 {
s := plural(l.Files, "changed file", "changed files")
if l.Modules > 1 {
s += fmt.Sprintf(" in %d repositories", l.Modules)
}
parts = append(parts, s)
}
if l.Operations > 0 {
parts = append(parts, plural(l.Operations, "operation in progress", "operations in progress"))
}
if l.Commits > 0 {
parts = append(parts, plural(l.Commits, "commit on no branch", "commits on no branch"))
}
if l.Repositories > 0 {
parts = append(parts, plural(l.Repositories, "untracked repository", "untracked repositories"))
}
if l.Unknown > 0 {
parts = append(parts, plural(l.Unknown, "repository of unknown state", "repositories of unknown state"))
}
return strings.Join(parts, ", ")
}
func plural(n int, one, many string) string {
if n == 1 {
return "1 " + one
}
return fmt.Sprintf("%d %s", n, many)
}
+153
View File
@@ -0,0 +1,153 @@
package tui
import (
"context"
"os"
"testing"
tea "github.com/charmbracelet/bubbletea"
zone "github.com/lrstanley/bubblezone"
"submarine/internal/submodule"
"submarine/internal/submodule/mock"
)
func TestMain(m *testing.M) {
zone.NewGlobal()
os.Exit(m.Run())
}
func TestLossText(t *testing.T) {
cases := []struct {
in submodule.Loss
want string
}{
{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"},
}
for _, c := range cases {
if got := lossText(c.in); got != c.want {
t.Errorf("lossText(%+v) = %q, want %q", c.in, got, c.want)
}
}
}
// fakeRestorer is the mock data with a restore that records its calls.
type fakeRestorer struct {
submodule.Provider
loss submodule.Loss
restored []string
}
func (f *fakeRestorer) RestoreLoss(context.Context, string) (submodule.Loss, error) {
return f.loss, nil
}
func (f *fakeRestorer) Restore(_ context.Context, path string) error {
f.restored = append(f.restored, path)
return nil
}
// run executes a command, flattening batches, and returns the messages.
func run(cmd tea.Cmd) []tea.Msg {
if cmd == nil {
return nil
}
msg := cmd()
if b, ok := msg.(tea.BatchMsg); ok {
var out []tea.Msg
for _, c := range b {
if c != nil {
out = append(out, run(c)...)
}
}
return out
}
return []tea.Msg{msg}
}
func press(m tea.Model, k string) (tea.Model, tea.Cmd) {
return m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune(k)})
}
// newRestoreModel returns a model with the mock tree loaded.
func newRestoreModel(t *testing.T, loss submodule.Loss) (tea.Model, *fakeRestorer) {
t.Helper()
f := &fakeRestorer{Provider: mock.New(), loss: loss}
var m tea.Model = New(context.Background(), f)
m, _ = m.Update(tea.WindowSizeMsg{Width: 120, Height: 30})
repo, err := f.Tree(context.Background())
if err != nil {
t.Fatal(err)
}
m, _ = m.Update(treeLoadedMsg{repo: repo})
return m, f
}
// feed delivers the messages of cmd to m, once.
func feed(m tea.Model, cmd tea.Cmd) tea.Model {
for _, msg := range run(cmd) {
switch msg.(type) {
case lossMsg, restoreDoneMsg:
m, _ = m.Update(msg)
}
}
return m
}
func TestRestoreWithoutLossRunsAtOnce(t *testing.T) {
m, f := newRestoreModel(t, submodule.Loss{})
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)
}
}
m = feed(m, next)
if len(f.restored) != 1 || f.restored[0] != "" {
t.Fatalf("restored %q, want the superproject once", f.restored)
}
if m.(Model).restore != nil {
t.Error("restore still in progress")
}
}
func TestRestoreAsksWhenSomethingIsLost(t *testing.T) {
m, f := newRestoreModel(t, submodule.Loss{Files: 3, Modules: 1})
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)
}
}
if next != nil || m.(Model).restore == nil || m.(Model).restore.phase != restoreAsking {
t.Fatalf("want a question, got %+v", m.(Model).restore)
}
// any other key cancels
c, _ := press(m, "n")
if c.(Model).restore != nil || len(f.restored) != 0 {
t.Fatal("n must cancel without restoring")
}
// y restores
m, cmd = press(m, "y")
m = feed(m, cmd)
if len(f.restored) != 1 {
t.Fatalf("restored %q", f.restored)
}
}
func TestRestoreWithoutRestorerSaysSo(t *testing.T) {
var m tea.Model = New(context.Background(), mock.New())
m, _ = m.Update(tea.WindowSizeMsg{Width: 120, Height: 30})
repo, _ := mock.New().Tree(context.Background())
m, _ = m.Update(treeLoadedMsg{repo: repo})
m, _ = press(m, "x")
if m.(Model).restore != nil {
t.Fatal("the mock can't restore")
}
}