diff --git a/AGENTS.md b/AGENTS.md index 073092e..488bd69 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -37,8 +37,9 @@ gofmt -l . # must print nothing state means (`Status.Issues()`). `internal/tui/theme` decides how it looks. Components never classify states or define colors themselves. - **Data access** only through `submodule.Provider` (and its optional - `Fetcher` / `Watcher`). The git provider only uses the network in - `Fetch`, and git must never prompt. + `Fetcher` / `Watcher` / `Restorer`). The git provider only uses the + 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 `docs/ux-guide.md#colors`). Render segments on a background with a `base` style (`LevelStyle(level, base)`, `Delta(pin, base)`). diff --git a/README.md b/README.md index 0a61d88..535c78d 100644 --- a/README.md +++ b/README.md @@ -111,6 +111,7 @@ files. | `/` | Filter | | `!` | Problems only | | `c` | Copy path | +| `x` | Restore: make the worktree exactly what git records (asks if work would be lost) | | `f` / `r` | Fetch / reload | | `?` | Full help | | `q` | Quit | @@ -119,8 +120,9 @@ The mouse works too: click to select, click `▸` to toggle, wheel to move. ## Status -A prototype. Browsing is complete and read-only; write actions (restore, -stage pin) are being specified in [docs/actions.md](docs/actions.md). +A prototype. Browsing is complete, and `x` restores a submodule or the whole +superproject. More write actions (stage pin) are being specified in +[docs/actions.md](docs/actions.md). ## Documentation diff --git a/docs/README.md b/docs/README.md index 5db1b93..c9da0a7 100644 --- a/docs/README.md +++ b/docs/README.md @@ -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 fetches only when asked (`f`) and follows changes made in other shells. `submarine --mock` shows the built-in mock data (`internal/submodule/mock`). `!` shows only -submodules with problems. It is read-only so far; the first write actions -are [drafted](actions.md). +submodules with problems. `x` restores the selection +([actions.md](actions.md)); stage pin is drafted there. 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. diff --git a/docs/actions.md b/docs/actions.md index d2364d8..8d085df 100644 --- a/docs/actions.md +++ b/docs/actions.md @@ -1,10 +1,8 @@ # Actions (draft) -**Status: draft, not implemented.** The spec for the first two write actions. -Open questions are listed [at the end](#open-questions). Once they are -answered, the decisions go into [decisions.md](decisions.md), and -[requirements.md](requirements.md) and [ux-guide.md](ux-guide.md) are -updated. +**Status:** [Restore](#restore) is implemented (key `x`, decisions in +[D23](decisions.md)). [Stage pin](#stage-pin) is still a draft; its open +questions are [at the end](#open-questions). ## 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 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` | 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 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 -and is the target. (See open question 2.) +and is the target. ### Postconditions @@ -51,7 +49,7 @@ After a successful restore of repository R with target T: revert. 3. **No untracked files or directories** remain in R. This includes leftover 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 it was never initialized, is inactive (`submodule..active`) or has `update = none / merge / rebase` configured. Its URL comes from T's @@ -94,75 +92,77 @@ absorbed submodule git directories stay. ### Confirmation -- **Nothing would be lost** (only `off pin`, `missing`, a staged pin of the - root): restore runs at once, without a question. -- **Something would be lost:** a confirmation line opens above the footer, - like the filter line. It says what is discarded, summed over the subtree: +Restore asks **only if something would be lost**. Never asking is unsafe +(`git clean -ff` deletes repositories that exist nowhere else, and nothing +undoes a restore); always asking puts a question on the harmless, common +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. -- **The state is not fully known** (a status in the subtree is still - loading or failed to load): the line says so (`… · 2 submodules not - 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. + `y` runs it, any other key cancels. +- **The state can't be read** for a repository: it is counted as `n + repositories of unknown state` and needs the same `y`. -The summary only covers the submodules that are loaded. Children of a -submodule that is not initialized are unknown. They are created fresh, so -nothing of them can be lost. +The check reads the disk at the moment of the keypress (`RestoreLoss`), not +the loaded statuses, so loading or stale statuses don't matter. Nothing is +lost that is not in the table above; ignored files are never touched. ### Network -A restore may need objects that are not on disk: - -- a submodule that was never cloned (`not initialized`, no git directory in - `.git/modules`); -- a target commit that was never fetched. - -Proposal: **restore may clone and fetch, but only for objects that are -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). +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 +never fetched. **Restore may clone and fetch, but only what is missing**, +non-interactively (`GIT_TERMINAL_PROMPT=0`, ssh `BatchMode`) and with a +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 +network (decision D23). ### Running, progress and errors -- The selected row (or the header) shows a spinner and `restoring` in the - status column until the restore ends. The header shows `restoring src/app` - where the fetch progress usually is. -- Only one write action runs at a time. Pressing `u` or `s` meanwhile - flashes `wait for the restore of src/app to finish`. Navigation and - everything else keep working. -- A running background fetch does not block a restore. -- Done: flash `restored src/app`. The watcher refreshes the affected rows. - The app also reloads the subtree, because the structure can have changed. -- Failure: the restore stops at the first repository that fails. The footer - flashes `restore failed in src/app/lib: `, 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. +- A line above the footer shows `checking …` and then `restoring …` with a + spinner. Only one restore runs at a time; pressing `x` meanwhile flashes + `wait for the restore of src/app to finish`. Everything else keeps working. +- A running fetch does not block a restore. +- Done: flash `restored src/app`; the app reloads the tree and all statuses, + because the structure can have changed. +- Failure: the restore stops with git's message, shown in red above the + footer (`restore of src/app failed: …`) until the next key. Restore is + idempotent, so it can be run again after fixing the cause. Repositories + that were restored before the failure stay restored. ### 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 -git merge --abort / rebase --abort / cherry-pick --abort / revert --abort # whichever is in progress -git checkout --detach --force T # submodule off the pin; superproject: git reset --hard HEAD -git reset --hard T -git clean -ffd # -ff: also untracked nested repositories -git submodule sync -git submodule init # all submodules of T, including inactive ones -# for each gitlink in T: make sure the repo exists (clone/absorb), then recurse +cat-file / fetch --all # only if T is missing locally +rebase|cherry-pick|revert --quit # whichever is in progress; --quit, not --abort: no ref moves +checkout --force --detach T # only if HEAD is not T, so a branch is never moved +reset --hard # also ends a merge +clean -ffd # -ff: untracked repositories too; ignored files stay +submodule sync +submodule update --init --checkout --force -- +# 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 -update --recursive`, so progress, the loss check and errors are per -repository. `git submodule update --init --checkout --force` is an option for -the clone step. The loss check runs right before the restore, against the -current state, not the displayed one. +`--checkout` overrides `submodule..update`, and the explicit paths +override `submodule..active`, so no child module is skipped. + +`RestoreLoss` (`lossOf`) follows the repositories on disk: per repository +`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 @@ -217,46 +217,41 @@ failure. ## Keys and palette 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` | -| `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` | +| `x` restore | Restore the selection | `this data source can't restore` (mock); `wait for the restore of … to finish` | +| `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 -- Provider: tests on fixtures from `scripts/make-fixture.sh`, which builds - every state. For restore: changed, untracked, untracked repository - (absorbed and standalone), conflict, rebase in progress, off pin on a - branch, off pin detached with unreachable commits, not initialized, - inactive, `update = none`, a submodule added and one removed between two - root commits, nested at depth ≥ 2. Assert the postconditions with - `git status --porcelain --ignored=no` and `git submodule status - --recursive`, and that branches are unchanged. For stage pin: index entry, - refusal on a dirty worktree, refusal when HEAD moved. -- Loss summary: unit tests on `submodule` (no UI), like `Status.Issues()`. -- UI: run it on a fixture in tmux (see - [architecture.md](architecture.md#testing)). Check the confirmation line, - the spinner, the flash messages and the palette. +- Provider (`restore_test.go`), on fixtures from `scripts/make-fixture.sh`: + a restored superproject is exact (every repository clean, `submodule status + --recursive` without prefixes, `libs/missing` initialized), branches are + not moved, ignored files survive, a merge and a rebase in progress end, a + standalone clone and a removed submodule's directory are deleted, a missing + submodule is cloned again, `update = none` and inactive submodules are + restored, an unavailable commit is reported. The loss is empty for off pin, + missing and unpushed submodules, counts files, nested changes, merges, + detached commits (not those on a branch) and standalone repositories. +- UI (`internal/tui/restore_test.go`): restores at once without a loss, asks + with one (`y` restores, anything else cancels), the mock can't restore. +- By hand in tmux on a fixture, see + [architecture.md](architecture.md#testing). ## Open questions -1. **Ignored files.** Should restore also delete ignored files (`git clean - -x`)? That is the literal "no additional files", but it deletes `.env`, - `node_modules`, build output and IDE settings in every submodule. - *Proposal: keep ignored files.* -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) - or the committed one (parent's `HEAD`, which also unstages)? - *Proposal: the index. Unstaging happens only through a superproject - restore.* -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? +Decided for restore: ignored files are kept (`clean` without `-x`); a +submodule is restored to the index gitlink, so a staged pin is its target and +survives; restore may clone and fetch what is missing; the key is `x`; it +asks only if something would be lost. + +Open (stage pin): + +1. **Key.** `s`? +2. **Palette.** Show it always (like restore), or only while it applies? diff --git a/docs/architecture.md b/docs/architecture.md index 06a4b94..9616c40 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -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 type Locator interface { Dir(path string) string // absolute directory, "" if unknown diff --git a/docs/decisions.md b/docs/decisions.md index 2dd405b..fea0fb9 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -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 never fetched. The progress display and per-submodule fetch errors stay as 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. diff --git a/docs/requirements.md b/docs/requirements.md index 284ebb2..8054525 100644 --- a/docs/requirements.md +++ b/docs/requirements.md @@ -128,10 +128,14 @@ commit hashes. It doesn't answer the questions users actually have: list is not mistaken for a healthy repository. See [Problems only](ux-guide.md#problems-only). -### Actions (planned) +### Actions -The tool is read-only so far. The first write actions, restore and stage -pin, are drafted in [actions.md](actions.md). +- **F56** `x` restores the selection (a submodule, or the superproject when + 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 @@ -154,7 +158,9 @@ submarine --mock # built-in mock data - **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 provider are interchangeable without UI changes. -- **N8** The git provider only uses the network in `Fetch`, and never - prompts (no password, passphrase or host-key questions; those fetches - fail with git's message). It uses the `git` command line, so the user's - git configuration applies. Apart from fetching, it is read-only. +- **N8** The git provider only uses the network in `Fetch` and, for objects + that are missing, in `Restore`. It never prompts (no password, passphrase + or host-key questions; those fail with git's message). It uses the `git` + 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. diff --git a/docs/ux-guide.md b/docs/ux-guide.md index a9a232c..d05a8a9 100644 --- a/docs/ux-guide.md +++ b/docs/ux-guide.md @@ -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 `. Without a local provider (mock) the path relative to the superproject is copied | | `/` | Open the filter, see [Filter](#filter) | | `!` | 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 grouped by directory). @@ -273,6 +274,24 @@ the term*. previous one. The superproject stays selectable. - 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: `, 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 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 | | `pgup`, `pgdn` | Move by one screen | Navigation | Help | | `c` | Copy path | Submodule | Palette, Help | +| `x` | Restore | Submodule | Palette, Help | | `f` | Fetch all repositories | Global | Help | | `r` | Reload everything | Global | Help | | `/` | Filter | Global | Help | diff --git a/internal/submodule/git/provider.go b/internal/submodule/git/provider.go index 1c5e625..f04e631 100644 --- a/internal/submodule/git/provider.go +++ b/internal/submodule/git/provider.go @@ -1,5 +1,6 @@ -// Package git provides a read-only submodule.Provider backed by the git -// command line. It never touches the network. +// Package git provides a submodule.Provider backed by the git command line. +// It reads; it writes only in Restore, and uses the network only in Fetch and +// in Restore (for what is missing). package git import ( @@ -32,6 +33,7 @@ type Provider struct { sem chan struct{} // bounds concurrent Status calls fetchSem chan struct{} // bounds concurrent 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 time.Duration diff --git a/internal/submodule/git/restore.go b/internal/submodule/git/restore.go new file mode 100644 index 0000000..5a293aa --- /dev/null +++ b/internal/submodule/git/restore.go @@ -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 +} diff --git a/internal/submodule/git/restore_test.go b/internal/submodule/git/restore_test.go new file mode 100644 index 0000000..fa4633a --- /dev/null +++ b/internal/submodule/git/restore_test.go @@ -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) +} diff --git a/internal/submodule/provider.go b/internal/submodule/provider.go index 15ea70a..cca2672 100644 --- a/internal/submodule/provider.go +++ b/internal/submodule/provider.go @@ -33,6 +33,42 @@ type Locator interface { 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. type Change struct { Path string // "" is the superproject diff --git a/internal/tui/app.go b/internal/tui/app.go index d5e1d7a..9c7a95a 100644 --- a/internal/tui/app.go +++ b/internal/tui/app.go @@ -56,6 +56,9 @@ type Model struct { // exactly then. The input is focused while typing and blurred after enter. 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 showDetail bool } @@ -156,7 +159,7 @@ func (m *Model) startFetch() tea.Cmd { // busy reports whether something shows a spinner. func (m Model) busy() bool { - if m.repo == nil || m.fetch.Running { + if m.repo == nil || m.fetch.Running || (m.restore != nil && m.restore.phase != restoreAsking) { return true } 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: return m.changed(msg) + case lossMsg: + return m.lossChecked(msg) + + case restoreDoneMsg: + return m.restoreDone(msg) + case fetchDoneMsg: m.fetch.Done++ 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) { 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() { return m.typeFilter(msg) } @@ -377,6 +393,8 @@ func (m Model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { m.filtering = true m.layout() return m, m.filter.Focus() + case key.Matches(msg, k.Restore): + return m.startRestore() case key.Matches(msg, k.Issues): m.tree.SetProblemsOnly(!m.tree.ProblemsOnly()) 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) } -// extraLine reports whether a line above the footer is shown: the filter, or -// the notice that only problems are shown. -func (m Model) extraLine() bool { return m.filtering || m.tree.ProblemsOnly() } +// extraLine reports whether a line above the footer is shown: a restore in +// progress, a failure, the filter, or the notice that only problems are +// 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 // 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()) 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()) } else if m.tree.ProblemsOnly() { parts = append(parts, m.problemsView()) diff --git a/internal/tui/keys.go b/internal/tui/keys.go index dc7e104..0d16028 100644 --- a/internal/tui/keys.go +++ b/internal/tui/keys.go @@ -7,7 +7,7 @@ type keyMap struct { CollapseAll, ExpandAll key.Binding Home, End, PageUp, PageDown key.Binding Fetch, Reload, Filter, Issues key.Binding - Copy, Help, Quit key.Binding + Copy, Restore, Help, Quit key.Binding } func defaultKeys() keyMap { @@ -27,6 +27,7 @@ func defaultKeys() keyMap { Reload: key.NewBinding(key.WithKeys("r"), key.WithHelp("r", "reload")), Filter: key.NewBinding(key.WithKeys("/"), key.WithHelp("/", "filter")), Issues: key.NewBinding(key.WithKeys("!"), key.WithHelp("!", "problems only")), + Restore: key.NewBinding(key.WithKeys("x"), key.WithHelp("x", "restore")), Copy: key.NewBinding(key.WithKeys("c"), key.WithHelp("c", "copy path")), Help: key.NewBinding(key.WithKeys("?"), key.WithHelp("?", "help")), 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 // 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. 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.CollapseAll, k.ExpandAll}, {k.Home, k.End, k.PageUp, k.PageDown}, - {k.Copy}, + {k.Copy, k.Restore}, {k.Fetch, k.Reload, k.Filter, k.Issues}, {k.Help, k.Quit}, } diff --git a/internal/tui/restore.go b/internal/tui/restore.go new file mode 100644 index 0000000..e4fe2e1 --- /dev/null +++ b/internal/tui/restore.go @@ -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) +} diff --git a/internal/tui/restore_test.go b/internal/tui/restore_test.go new file mode 100644 index 0000000..5e1de90 --- /dev/null +++ b/internal/tui/restore_test.go @@ -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") + } +}