Archived
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:
@@ -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)`).
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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?
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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.
|
||||||
|
|||||||
@@ -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 |
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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
@@ -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())
|
||||||
|
|||||||
@@ -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},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user