11 Commits
Author SHA1 Message Date
simonfrankenandClaude Opus 5.5 b9f6055759 Restore keeps staged pins; offer the committed pin for a staged one
Staged pins survive a restore at every depth and are the targets of the
submodules below. For a submodule with a staged pin, x asks whether to
restore to the staged or the committed pin, the latter unstaging it.
Staged pins of a repository that moves count as a loss.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 12:42:20 +02:00
simonfrankenandClaude Opus 5.5 7980d3356a 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>
2026-10-22 12:38:29 +02:00
simonfrankenandClaude Sonnet 5.5 a328d3cc3a Remove the unimplemented u and b keys
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-22 12:19:35 +02:00
simonfrankenandClaude Sonnet 5.5 a285a35c95 Fetch only on request, remove --no-fetch
Nothing is fetched on startup any more; the header and detail pane show
the last fetch and f fetches everything. Records the change as D22.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-22 12:13:01 +02:00
simonfrankenandClaude Sonnet 5.5 928e76154f Add problems-only view (!) and draft the write actions
`!` reduces the tree to submodules with issues (or unknown state) plus
their ancestors, reusing the filter's reduction. Adds docs/actions.md as
a draft spec for restore and stage pin.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-22 12:12:11 +02:00
simonfrankenandClaude Sonnet 5.5 72c060882b Make the superproject selectable via the header
The header stands for the root repository: it is selected on startup,
shows the root's own state word, and the detail pane shows its state
like a submodule's (HEAD vs. upstream, changes, unpushed, leftover
submodule clones as untracked repositories). Submodules are ignored in
the root's changes; their rows show them.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-22 11:54:40 +02:00
simonfrankenandClaude Sonnet 5.5 f9838c0c99 Fix flaky TestWatch when a fetch is reported in two rounds
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-22 10:24:11 +02:00
simonfrankenandClaude Sonnet 5.5 651ab10f51 Use sub as the default command in the README
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-22 10:18:34 +02:00
simonfrankenandClaude Sonnet 5.5 e0f4ac823b Show staged pins in the tree
A staged pin was only visible in the detail pane, so the header's
"n pin staged" could not be traced to a row. The status column now shows
a blue "staged" instead of "in sync", a blue marker after an issue word,
and the marker on collapsed rows with a staged pin below. It is still not an
issue and not counted in +n.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-22 10:14:35 +02:00
simonfrankenandClaude Sonnet 5.5 83824e42c1 Add a README with a screenshot and examples
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-21 21:28:49 +02:00
simonfrankenandClaude Sonnet 5.5 f5fabfd093 Add / filter for the tree
A text input above the footer filters submodules by path (case-insensitive).
Matches keep their ancestors. A filtered-out selection moves to the next
visible submodule; esc closes the input and removes the filter.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-21 21:15:24 +02:00
34 changed files with 3183 additions and 201 deletions
+3 -3
View File
@@ -20,7 +20,6 @@ and decisions. Read the relevant document before changing behavior:
```sh
go run ./cmd/submarine [dir] # run on a repository (default: cwd)
go run ./cmd/submarine --mock # run on mock data
go run ./cmd/submarine --no-fetch [dir] # without background fetch
scripts/make-fixture.sh DIR # build a test superproject in DIR
make dist # cross-compile into dist/
GITEA_TOKEN=… make release # publish a tagged release (outward-facing)
@@ -38,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)`).
+138
View File
@@ -0,0 +1,138 @@
# submarine
**A terminal UI for git repositories with many submodules.**
See what needs your attention. Ignore the rest.
`git submodule status` gives you a wall of commit hashes. submarine answers
the questions you actually have:
- Which branch is this submodule on, and is the pin current?
- Which submodules need me, and which are fine?
- Is anything dangerous, like a pin nobody else can fetch?
![submarine showing a tree with nested submodules and the detail pane of an off-pin submodule that is one commit behind its branch](docs/images/overview.png)
## Why you might like it
- **Only submodules, as a tree.** Nested submodules at any depth, grouped by
directory, collapsed until you open them.
- **Branch plus distance, not hashes.** `origin/main ↓2` says more than
`b7c8d9e`. A pin on a feature branch shows that branch.
- **Problems are loud, healthy is quiet.** Submodules without issues are
muted `in sync`; a staged pin shows as blue `staged` (or a `●` after an
issue word). A collapsed parent counts the problems hidden below it
(`·1`) and shows `●` if a staged pin is hidden.
- **Words, not symbols.** Every state is a short word (`unpushed`, `conflict`,
`missing`, `off pin`, `changed`), so nothing depends on color alone.
- **Live.** Follows changes you make in other shells within about two
seconds. It never fetches on its own: the header shows when you last did,
`f` fetches everything.
- **Built for dozens to hundreds of submodules.** Each status loads
asynchronously, so the UI never freezes.
## Install
```sh
curl -fsSL https://git.simon-franken.de/simonfranken/submarine/raw/branch/main/install.sh | sh
```
Installs `sub` and `submarine` (linux/macOS, amd64/arm64) to `~/.local/bin`.
Both names start the same program; the examples below use the shorter `sub`.
Update later with `sub update`.
## Try it
```sh
sub # the repository in the current directory
sub ~/code/project # any directory inside a repository
sub --mock # built-in sample data, no repository needed
```
`submarine` works everywhere `sub` does, e.g. `submarine --mock`.
## Examples
### Find what needs attention
Problems stand out: `unpushed` in red, `off pin` and `changed` in yellow,
everything else muted. Select a submodule to see the details on the right:
every issue in words, the resolved ref and why, the pinned, checked-out and
tip commits, and a preview of uncommitted changes.
```
src/ │ src/
core off pin origin/main │ net
▸ net [2] unpushed origin/main ↑1│
ui in sync origin/main │ unpushed pin not pushed to any remote
│
│ Ref origin/main ↑1
│ via checked out branch
│ Pinned b7c8d9e Hotfix null pointer
│ Checkout b7c8d9e Hotfix null pointer
│ Tip 6f5e4d3 Release 2.1.0
```
### Jump to a submodule with `/`
Press `/` and type. Only matching submodules (and the parents that lead to
them) stay. Your selection moves to the next match if it was filtered out.
```
libs/
▾ log in sync origin/main
modules/
nested changed origin/main
/ nest enter navigate • esc close
```
`enter` keeps the filter and gives you the normal keys back, `esc` removes
it and leaves your selection where it is.
### Copy a path
Select a submodule and press `c`. Its absolute directory lands in your
clipboard, ready for `cd <paste>`. It works over SSH too (OSC 52; in tmux,
`set -g set-clipboard on`).
### Fetch and reload
`f` fetches all repositories again, `r` reloads everything after you edited
files.
## Keys
| Key | Action |
|---|---|
| `↑` `↓` / `j` `k` | Move |
| `←` `→` / `h` `l` | Fold / unfold, parent / first child |
| `H` `L` / `⇧←` `⇧→` | Collapse / expand all |
| `space` | Toggle the selected submodule |
| `home` `end` / `g` `G`, `pgup` `pgdn` | Jump, page |
| `/` | 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 |
The mouse works too: click to select, click `▸` to toggle, wheel to move.
## Status
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
Requirements, state rules, UX guide, architecture and the decision log are in
[docs/](docs/README.md).
## Development
```sh
go run ./cmd/submarine --mock # run on mock data
scripts/make-fixture.sh DIR # build a test superproject with every state
go vet ./... && go test ./...
```
+4 -5
View File
@@ -35,9 +35,8 @@ func main() {
}
useMock := flag.Bool("mock", false, "use built-in mock data instead of a real repository")
noFetch := flag.Bool("no-fetch", false, "don't fetch remotes in the background on startup")
flag.Usage = func() {
fmt.Fprintln(flag.CommandLine.Output(), "usage: submarine [--mock] [--no-fetch] [dir]\n submarine update [--check] [--force]\n submarine version")
fmt.Fprintln(flag.CommandLine.Output(), "usage: submarine [--mock] [dir]\n submarine update [--check] [--force]\n submarine version")
flag.PrintDefaults()
}
flag.Parse()
@@ -72,9 +71,9 @@ func main() {
zone.NewGlobal()
defer zone.Close()
notice := checkForUpdate(ctx, *noFetch || *useMock)
notice := checkForUpdate(ctx, *useMock)
p := tea.NewProgram(tui.New(ctx, provider, tui.Options{Fetch: !*noFetch}), tea.WithAltScreen(), tea.WithMouseCellMotion())
p := tea.NewProgram(tui.New(ctx, provider), tea.WithAltScreen(), tea.WithMouseCellMotion())
if _, err := p.Run(); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
@@ -90,7 +89,7 @@ func main() {
// checkForUpdate looks for a newer release in the background (at most once a
// day, cached) and delivers its version, or "" when there is none. Disabled
// for dev builds, --no-fetch, --mock and SUBMARINE_NO_UPDATE_CHECK.
// for dev builds, --mock and SUBMARINE_NO_UPDATE_CHECK.
func checkForUpdate(ctx context.Context, off bool) <-chan string {
out := make(chan string, 1)
cache, err := os.UserCacheDir()
+9 -7
View File
@@ -1,7 +1,8 @@
# submarine — documentation
submarine is a terminal UI for git repositories with many (recursively nested)
submodules. It shows **only the submodules**, as a collapsible tree, and
submodules. It shows **the submodules**, as a collapsible tree (plus the state
of the superproject itself in the header), and
describes each pin as a **branch plus distance** (`origin/main ↓1`) instead of a
bare commit hash.
@@ -13,6 +14,7 @@ bare commit hash.
| [state-model.md](state-model.md) | How a submodule's state is determined, summarized into issues, and how pins are resolved to refs |
| [ux-guide.md](ux-guide.md) | Layout, attention hierarchy, colors, wording, navigation and key bindings |
| [architecture.md](architecture.md) | Package layout, the provider boundary, async loading, testing |
| [actions.md](actions.md) | **Draft:** the write actions restore and stage pin |
| [decisions.md](decisions.md) | Decision log with rationale (ADR style) |
## Install
@@ -29,12 +31,12 @@ Installs `submarine` and the alias `sub` (linux/macOS, amd64/arm64) to
**Prototype.** The UI is complete for browsing. It reads real repositories
through a git provider (`internal/submodule/git`): `submarine [dir]`. It
fetches in the background on startup (`--no-fetch` to skip; `f` to fetch
again) and follows changes made in other shells. `submarine --mock` shows
the built-in mock data (`internal/submodule/mock`). The actions `u`, `b`,
`/`, `!` only show a "not implemented" message.
fetches only when asked (`f`) and follows changes made in other shells. `submarine --mock` shows
the built-in mock data (`internal/submodule/mock`). `!` shows only
submodules with problems. `x` restores the selection
([actions.md](actions.md)); stage pin is drafted there.
Next steps, roughly in order:
1. Actions: checkout pin (`submodule update`), bump pin to tip, init.
2. Filter (`/`) and "problems only" (`!`) views.
1. Stage pin, see [actions.md](actions.md). (Restore is done.)
2. (done) "Problems only" (`!`) view, reusing the tree's filter.
+288
View File
@@ -0,0 +1,288 @@
# Actions (draft)
**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
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 |
|---|---|---|
| [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
bumping a pin to the tip are done outside submarine. A bump is "check out
the tip in the submodule, then `s`".
## Restore
Makes the selected repository, and everything below it, exactly what git
records. It works on any submodule and on the superproject (header
selected). Restoring the superproject gives the exact project that the
current root commit specifies, with the pins you have staged.
### Target
| Selected | Target commit | Branch |
|---|---|---|
| Superproject | `HEAD` | stays on the current branch |
| Submodule | The pin in the parent's **index** (what the UI shows as *Pinned*); with a staged pin you choose, see below | see [Branches](#branches) |
| Nested submodule, during the recursion | The gitlink in the restored parent's index | as for a submodule |
**Staged pins are kept, at every depth.** A staged pin is a decision, not
drift. Each repository's files are reset to its `HEAD`, but its staged pins
stay in its index, and the submodules below are restored to them. So a
nested submodule with a staged pin and uncommitted changes keeps the pin
and is reset to it; its changes are discarded (and listed in the question).
**Choosing the pin.** If the selected submodule has a staged pin itself, `x`
first asks which pin to restore to:
```
Restore libs/staged to s staged pin · c committed pin (unstages it) any other key cancels
```
- `s`: the staged pin, which stays staged.
- `c`: the committed pin (the gitlink in the parent's `HEAD`). The parent's
index is set back to it, which is how a pin is unstaged in submarine.
The loss check then runs for the chosen pin (the two can differ: a staged
commit that exists only locally is lost by `c`, not by `s`).
Two exceptions, where a staged pin is not kept:
- **The repository moves.** If a repository is off its pin, restore checks
out another commit; staged pins in its own index belonged to the old one.
They are counted as a loss (`2 staged pins`), so the question appears. Its
submodules follow the target commit's tree.
- **An added, not yet committed submodule** is no staged pin: its
`.gitmodules` entry is reset with the other files. Its directory then
counts as an untracked file.
### Postconditions
After a successful restore of repository R with target T:
1. **R's HEAD is T.** The index and the worktree equal T's tree, except for
the staged pins that were kept. No modified, added, removed or conflicted
files remain.
2. **No in-progress operation** remains in R: merge, rebase, cherry-pick,
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.
4. **Every submodule that R's index records is present and initialized**, even if
it was never initialized, is inactive (`submodule.<name>.active`) or has
`update = none / merge / rebase` configured. Its URL comes from T's
`.gitmodules` (synced into the config).
5. **Each of those submodules is restored the same way**, with its gitlink
in R's index (T's, or a kept staged pin) as its target, recursively at
any depth.
So the subtree shows `in sync` everywhere, except for states restore does
not change: `unpushed`, behind/ahead of the ref, and `detached HEAD on pin`.
The recursion follows **T**, not the tree loaded in the UI. A submodule that
is only in T (for example, after switching branches) is initialized. A
submodule that is only on disk is deleted as an untracked directory.
### Branches
- **Superproject:** stays on its branch. The branch is not moved, since the
target is its own `HEAD`.
- **Submodule on the pin:** if HEAD is a branch that points at the target,
the branch stays checked out.
- **Submodule off the pin:** HEAD is detached at the target. The branch that
was checked out is **not moved**, so its commits stay on it.
Restore never moves, creates or deletes a branch.
### What can be lost
Restore discards work. The things it can lose:
| Lost | Where it comes from |
|---|---|
| Changed files | modified, added, removed, renamed files (`changed`) |
| Untracked files | `changed` (untracked) |
| Conflicts and in-progress operations | `conflict`; merge, rebase, cherry-pick, revert state |
| Commits | A detached HEAD off the pin whose commits are on no branch, tag or remote ref (only the reflog still has them) |
| Staged pins | Staged pins in a repository that restore moves to another commit (see [Target](#target)) |
| Repositories | An untracked directory that is a repository with its **own** `.git` directory (a clone that was never added as a submodule). Its commits may exist nowhere else. A removed submodule whose git directory is absorbed into `.git/modules` loses only its worktree. Its objects stay |
Nothing else is lost. Branches, stashes, remote refs, ignored files and
absorbed submodule git directories stay.
### Confirmation
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 repositories, 2 commits on no branch, 1 untracked repository. y restore · n cancel
```
`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 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 (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
- 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
`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
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
update-index --cacheinfo … # put the staged pins back (only if HEAD did not move)
clean -ffd # -ff: untracked repositories too; ignored files stay
submodule sync
submodule update --init --checkout --force -- <not yet initialized children>
# then every gitlink of the index (ls-files -s) is restored the same way
```
`--checkout` overrides `submodule.<name>.update`, and the explicit paths
override `submodule.<name>.active`, so no child module is skipped.
With `c` the parent's index gets the committed pin first (`update-index
--cacheinfo` in the parent).
`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
Records the commit checked out in a submodule as its new pin in the
parent's index. It is the same as `git add <path>` in the parent, but only
when it is safe.
### Available when
All of:
- the selected node is a submodule, not the superproject;
- it is `off pin` (`Checkout == CheckoutDiffers`): HEAD differs from the
pin in the parent's index;
- its worktree is **clean** (`Worktree == WorktreeClean`). Uncommitted
changes, untracked files and conflicts count as dirty, the same rule as
the `changed` issue. A moved nested pin counts as a change too, so nested
submodules are staged bottom-up: first inside the submodule, then the
submodule itself (after a commit in it, outside submarine).
Changes *inside* nested submodules don't count. Like the `changed` issue,
staging only looks at the submodule's own worktree.
### Effect
- The parent's index records the submodule's HEAD as the gitlink of its
path. Nothing else changes: no worktree, no other index entry, no commit.
- The submodule then shows `staged` (or `●` next to an issue), and the
`off pin` issue is gone.
- If the checkout equals the pin in the parent's `HEAD`, staging removes
the staged pin instead (the index equals `HEAD` again).
- If the checked-out commit is not pushed, it is staged anyway. The row
then shows `unpushed` (Danger), which is the warning.
- There is no confirmation: nothing is lost.
- Undo: restore the submodule and choose `c` (committed pin), see
[Target](#target).
### Implementation note
```sh
# in the submodule
git rev-parse HEAD # must still be the SHA shown in the UI, else abort
# in the parent
git update-index --cacheinfo 160000,<sha>,<path>
```
`update-index` stages exactly the SHA that was checked, independent of the
worktree. Flash: `staged pin of src/app` on success; git's message on
failure.
## Keys and palette
Both are submodule keys (`keyMap.Submodule`), shown in the detail pane's
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 | Action | Pressed when not possible (flash) |
|---|---|---|
| `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` |
`R` would sit next to `r` (reload), a dangerous mix-up for a destructive
action, hence `x`.
## Testing
- 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
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?
+11 -4
View File
@@ -64,6 +64,13 @@ type Watcher interface {
}
```
```go
type Restorer interface {
RestoreLoss(ctx context.Context, path string) (Loss, error) // what a restore would discard
Restore(ctx context.Context, path string) error // may clone/fetch what is missing
}
```
```go
type Locator interface {
Dir(path string) string // absolute directory, "" if unknown
@@ -119,9 +126,9 @@ The mock provider (`internal/submodule/mock`) covers every state and sleeps
calling `Status()` → `statusLoadedMsg`.
3. Each result is stored on the submodule (`Status` or `Err`); the view
updates incrementally, including parents' hidden-issue counts.
4. After the first tree: the watcher starts (`changeMsg`), and the
background fetch starts one `tea.Cmd` per fetchable repository
(`fetchDoneMsg`).
4. After the first tree: the watcher starts (`changeMsg`). Nothing is
fetched until the user presses `f`, which starts one `tea.Cmd` per
fetchable repository (`fetchDoneMsg`).
5. The spinner ticks only while something loads or fetches.
### Fetch and refresh are decoupled (D20)
@@ -197,7 +204,7 @@ No CI runner: releases are built and published from a developer machine.
so `sub` works too). `SUBMARINE_UPDATE_URL` overrides the registry.
- After the TUI exits, a one-line notice is printed if a newer release
exists. The check runs in the background at startup, at most once a day
(cached in the user cache dir) and is off for dev builds, `--no-fetch`,
(cached in the user cache dir) and is off for dev builds,
`--mock` and `SUBMARINE_NO_UPDATE_CHECK=1`.
## Testing
+119 -1
View File
@@ -150,7 +150,7 @@ removed + untracked.
**Consequences.** One extra `git diff` per dirty submodule, none for clean
ones. Not included: staged/unstaged markers, diff bodies, stash count.
## D18 — Background fetch on startup
## D18 — Background fetch on startup (superseded by D22)
**Context.** Without fetching, every answer is only as fresh as the user's
last `git fetch`, which they rarely run in every submodule.
@@ -231,3 +231,121 @@ those preferred refs, by commits on either side: scanning every branch's
merge base would be too slow. Cost: a pin on main far behind its tip shows a
younger branch that contains it if that branch's tip is closer, so "n behind
main" is no longer visible there; `via <rule>` still tells which ref it is.
## Filter: matches with ancestors, lives as long as the input
`/` filters by case-insensitive substring of the submodule path. Matches
keep their ancestors, shown expanded, because a nested submodule alone
would lose its context; the user's fold state is not touched, so closing the
filter restores it (plus the selection's ancestors, so the selection stays
visible). The filter exists exactly while the input line is open, and `esc`
closes both, so there is no hidden filter that makes the list look
incomplete. While typing, `j`/`k`/`q` are text; `enter` returns the keys to
the tree and keeps the filter, so a filtered list can be navigated with the
usual keys. A filtered-out selection moves to the next visible submodule
(previous if none follows) instead of the first, so the cursor stays near
where it was. This adds the `bubbles/textinput` dependency (and its
`atotto/clipboard` import).
## Staged pin is shown in the tree, but is not an issue
A staged pin was only visible in the detail pane, so the header's `1 pin
staged` could not be traced to a row, and `in sync nothing to do` was
wrong for a submodule whose pin change still has to be committed. The tree
now shows it in the status column: blue `staged` replaces `in sync`; next to
an issue word it is a blue `●` (an issue wins the word, since the cell holds
one word, D3). It is not counted in `+n`, because `+n` and the header count
issues and a staged pin stays quiet (D4). A collapsed row shows `●` if a
staged pin is hidden below it (`Hidden.Staged`), placed before the `·k`
counts so truncation of the 16-column cell cuts the counts first.
## Superproject is selectable in the header
The state of the root repository was invisible: a submodule the current
branch no longer has stays on disk as an untracked repository, root files
can be modified, HEAD can be unpushed. The root becomes a node with an
empty path (the convention `Fetcher`/`Change` already use), selected on
startup, and the **header** is its row: it already holds the root's path,
branch and ahead/behind, and a tree row would cost a line and an indent
level for an item that is not part of the tree. Selecting the header shows
the usual detail pane; the header also shows the root's state word, so a
problem is visible while a submodule is selected.
- *Changes ignore submodules* (`--ignore-submodules=all`): off-pin and staged
pins already have their rows; counting them again would make the root
`changed` in most real repositories.
- *Unpushed stays Danger*: same issue kind and level as a submodule, one
rule for "work that exists only here" (D4).
- *The ref is the upstream*, not the closest branch: the header shows
ahead/behind of the upstream, and "up to date" means relative to the
tracked branch. A nearer feature branch would make the two disagree.
Without an upstream the usual resolution applies.
- The root is not in `Walk`, so counts, fetch and loading logic stay
submodule-only; the app tracks it by the path `""`.
## Problems only reuses the filter's tree reduction
`!` shows only submodules with issues. Instead of a second view, the tree's
filter got a second criterion: a submodule matches if its path contains the
term *and*, with problems only, it has an issue. Ancestors of matches stay as
context, expanded, so a danger deep in the tree is reachable without
unfolding anything. Unlike the filter text it is a mode that outlives the
filter line, so it has its own notice line above the footer: a short list
must not look like a healthy repository (D4). Loading and failed submodules
count as problems because their state is unknown (D7 counts `error` as danger
too); the cost is that rows vanish as statuses arrive, so the selection is
refitted after each status. Staged pins are not shown by it, since they are
not issues.
## D22 — Fetch only on request
**Context.** D18 fetched every repository on startup. With hundreds of
submodules that is hundreds of network round trips (and credential or
host-key failures) every time the tool opens, whether or not the user wants
fresh remote data.
**Decision.** Supersedes D18. Nothing is fetched on startup. The header and
the detail pane show the last fetch (`FETCH_HEAD`, also from fetches made in
other shells), so the user can judge how stale the remote refs are and press
`f` to fetch. The `--no-fetch` flag is removed with it (it still had the
side effect of switching off the update check; that has
`SUBMARINE_NO_UPDATE_CHECK`).
**Consequences.** Right after startup, `unpushed`, `↓n` and the like are as
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).
- *Staged pins are kept, at every depth.* The first version reset the
superproject's index and so dropped staged pins silently (they were not
counted as a loss). A staged pin is a decision, not drift: each
repository's staged pins are put back after its reset and are the targets
of its submodules. Unstaging gets a place instead: for a submodule with a
staged pin, `x` asks *staged* (`s`) or *committed* (`c`, unstages it). That
asks only where the intent is ambiguous and needs no extra key. Staged pins
of a repository that moves to another commit can't be carried over and
count as a loss.
**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.
Binary file not shown.

After

Width:  |  Height:  |  Size: 127 KiB

+50 -22
View File
@@ -11,7 +11,8 @@ commit hashes. It doesn't answer the questions users actually have:
## Goals
- Show **only submodules** (not files), recursively nested, as a tree.
- Show **only submodules** (not files), recursively nested, as a tree; the
superproject itself is shown by the header, which is selectable.
- Describe pins as **ref + distance** instead of a commit hash.
- Let **problems stand out** and keep healthy submodules quiet.
- Work comfortably with **dozens to hundreds** of submodules.
@@ -35,7 +36,7 @@ commit hashes. It doesn't answer the questions users actually have:
e.g. `[2]`.
- **F6** A collapsed submodule shows the number of issues **hidden** in all
its descendants (`·1 ·2`), one count per level: errors in red, then
warnings in yellow.
warnings in yellow. A staged pin below shows as a blue `●` before them.
- **F7** Expansion state of descendants is remembered when a parent is
collapsed and restored when it is expanded again (except after
"collapse all", which resets it).
@@ -44,7 +45,8 @@ commit hashes. It doesn't answer the questions users actually have:
- **F10** Each submodule shows at most **one** state word: the most severe
issue, plus `+n` if there are more. Submodules without issues show a
muted `in sync`.
muted `in sync`, or a blue `staged` if their pin change is staged. With
issues, a staged pin adds a blue `●` after the word.
See [state-model.md](state-model.md) for the full rules.
- **F11** Each submodule shows its pin as a ref (`origin/main`, a tag, or a
short SHA as a fallback) and the distance to that ref (`↑n`, `↓n`, `↑n↓m`).
@@ -55,7 +57,7 @@ commit hashes. It doesn't answer the questions users actually have:
### Detail pane
- **F20** Shows the selected submodule: full path, name, all issues in
- **F20** Shows the selected submodule (or the superproject): full path, name, all issues in
words, staged-pin and detached-HEAD notes.
- **F21** Shows the resolved ref, how it was resolved, and three commits:
**Pinned**, **Checkout** and **Tip** (SHA + subject). A checkout that
@@ -71,14 +73,23 @@ commit hashes. It doesn't answer the questions users actually have:
shown; the rest is summarized as "n more" with their kinds. Staged and
unstaged changes are combined.
- **F25** The superproject is selectable (the header; selected on startup)
and has a state of its own, so a stale checkout shows up: files changed
or untracked, untracked repositories (e.g. a submodule clone the current
branch no longer has), conflicts, and HEAD not pushed. Its HEAD is compared
with the upstream of the branch. Submodules do not count for it, their rows
show them. The detail pane shows it like a submodule (HEAD and Tip instead
of Pinned and Checkout, a summary of all submodules instead of Children).
See [state-model.md](state-model.md#superproject).
### Header and footer
- **F30** The header shows the superproject path, its branch and
ahead/behind, the number of submodules, a count per issue kind (most
ahead/behind, its own state word (**F25**), the number of submodules, a count per issue kind (most
severe first), staged pins, how many are still loading, and the time since
the last fetch.
- **F31** The footer shows key hints; `?` toggles the full help. It
temporarily shows short messages (e.g. "not implemented").
temporarily shows short messages (e.g. "copied <path>").
### Navigation
@@ -89,9 +100,10 @@ commit hashes. It doesn't answer the questions users actually have:
### Fetch and refresh
- **F50** On startup, after the local state is shown, the superproject and
every initialized submodule are fetched in the background (all remotes,
with prune). `--no-fetch` turns this off.
- **F50** Nothing is fetched on startup. The header and the detail pane show
when the repositories were last fetched (`FETCH_HEAD`), whoever did it.
`f` fetches the superproject and every initialized submodule in the
background (all remotes, with prune).
- **F51** While fetching, the header shows progress (`fetching 12/174`)
instead of the fetch age; afterwards the age and the number of failed
fetches. The detail pane shows each submodule's last fetch and, if it
@@ -103,27 +115,41 @@ commit hashes. It doesn't answer the questions users actually have:
- **F53** `f` fetches again; `r` reloads everything (e.g. after editing
files, which is not watched).
### Actions (planned, not implemented)
- **F54** `/` filters the tree by name/path (case-insensitive substring).
A text input above the footer takes the term; `esc` closes it and removes
the filter. A selection that is filtered out moves to the next visible
submodule (none if nothing matches); closing the filter keeps the
selection (the superproject if none is left). See [Filter](ux-guide.md#filter).
| Key | Action |
|---|---|
| `u` | Check out the pinned commit (`git submodule update`) |
| `b` | Bump the pin to the tip of its ref |
| `/` | Filter by name/path |
| `!` | Show only submodules with issues |
- **F55** `!` toggles "problems only": the tree shows only submodules with
an issue (**F10**) or an unknown state (loading, `error`), plus their
ancestors, expanded. It combines with the filter (**F54**) and stays when
the filter closes. A line above the footer says `problems only`, so a short
list is not mistaken for a healthy repository. See
[Problems only](ux-guide.md#problems-only).
### Actions
- **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. Staged pins are kept at every
depth. For a submodule with a staged pin it asks whether to restore to the
staged or the committed pin (the latter unstages it). It asks for
confirmation only if something would be lost. See [actions.md](actions.md#restore).
Stage pin is drafted in [actions.md](actions.md#stage-pin).
## Usage
```sh
submarine [dir] # the repository containing dir (default: current directory)
submarine --mock # built-in mock data
submarine --no-fetch [dir] # no background fetch on startup
```
## Non-functional requirements
- **N1** Startup must not block on the network. The tool shows the local
state at once and fetches in the background (F50).
state at once and uses the network only when the user asks (F50).
- **N2** Loading many submodules must not freeze the UI (async, per
submodule).
- **N3** Colors follow the user's terminal theme (ANSI 16 colors); the UI
@@ -134,7 +160,9 @@ submarine --no-fetch [dir] # no background fetch on startup
- **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.
+20
View File
@@ -82,6 +82,8 @@ Rules:
changed.
- **Not issues** (quiet): in sync, pin behind/ahead/diverged but pushed,
staged pin, detached HEAD on the pin. They are shown as information only.
A staged pin is also visible in the tree (`staged` or a `●` marker), see
[ux-guide.md](ux-guide.md#tree-row), but is never counted as an issue.
- A loading error is shown as `error` (Danger) in the UI; it is not an issue
kind.
@@ -101,6 +103,24 @@ per level, red first, then yellow, so a danger can never hide inside a
collapsed subtree and a warning is never mistaken for one.
A descendant whose load failed (`error`) counts as one danger.
## Superproject
The superproject is a node with an empty `Path` (`Repo.Root`, no children;
`Submodule.IsRoot()`) and a `Status` with `Root` set. It reuses the pin
fields with **pin = HEAD**: `Pinned` and `CheckedOut` are HEAD, `Checkout`
is never `CheckoutDiffers` (`CheckoutDetached` for a detached HEAD),
`PinStaged` is false. `Issues()` is the same code; only `unpushed` says
"HEAD not pushed to any remote" (Danger, like a submodule's pin).
- **Ref** is the upstream of the current branch (`RefFromCurrentBranch`), so
ahead/behind agrees with the header. Closest-branch resolution is only the
fallback without an upstream (detached, or no upstream configured).
- **Changes** ignore submodules (`status --ignore-submodules=all`): an off-pin
or staged submodule is shown by its own row, not twice. A leftover
directory of a submodule the branch no longer has is an untracked
repository.
- `Walk` and the header counts do not include the root.
## Resolving a pin to a ref
The pin is displayed as a ref, not a hash. A commit can be on several
+106 -30
View File
@@ -34,6 +34,7 @@
| Header | 1 line |
| Tree | Remaining width |
| Detail pane | 40% of the width, at least 36 columns; hidden below 90 columns |
| Filter line | 1 line above the footer, only while filtering |
| Footer | 1 line (more with full help) |
### Key bar and palette
@@ -62,7 +63,7 @@ indent ▸ name [n] status ref delta
| indent | 2 per depth | |
| toggle | 3 | `▸` collapsed, `▾` expanded, blank for leaves |
| name | fits the widest visible name | name, `[n]` = direct children (collapsed only) |
| status | 16 | issue word, `+n`, `·k` hidden issues per level (red, then yellow); muted `in sync` without issues; spinner while loading |
| status | 16 | issue word, `+n`, `●` staged pin, `·k` hidden issues per level (red, then yellow); blue `staged` or muted `in sync` without issues; spinner while loading |
| ref | widest ref, max 22 | resolved ref; muted short SHA if unresolved |
| delta | 6 | `↑n`, `↓n`, `↑n↓m` |
@@ -74,10 +75,15 @@ indent ▸ name [n] status ref delta
### Detail pane
For the superproject (header selected), `Pinned` / `Checkout` become one
`HEAD` line, the commit list says "HEAD is n commits behind …", and
`Children` becomes `Modules` (count and issues of all submodules). See
[Superproject](state-model.md#superproject).
1. Directory (muted) and name (bold)
2. One line per issue: word (colored, 10 columns) + explanation;
`in sync nothing to do` if there are none; `staged` and
`detached HEAD on pin` as information
`in sync nothing to do` if there are none and no pin is staged;
`staged` and `detached HEAD on pin` as information
3. `Changes` (only with uncommitted changes), see
[Change preview](#change-preview)
4. `Ref` with distance and `via <rule>`; `Fetched <age>` of the submodule;
@@ -135,7 +141,13 @@ Changes 5 files · 2M 1A 1D 1R · +37 −12
- Untracked directories are shown as one entry (`tmp/`).
### Header
`path branch ↑↓ · n submodules · n error · <count> <issue>… · n pin staged · n loading · <fetch>`
The header is the superproject's row: it is selected on startup and drawn
with the selected background (bold) while selected. After the branch it
shows the superproject's own state word like a tree row (`in sync`,
`changed`, `unpushed +1`, spinner, `error`). The counts that follow are
about submodules only.
`path branch ↑↓ state · n submodules · n error · <count> <issue>… · n pin staged · n loading · <fetch>`
Issue counts are ordered by severity (unpushed, conflict, missing, off pin,
changed), each in its level color. Zero counts are omitted. `n loading`
@@ -164,7 +176,7 @@ colors in components.
|---|---|---|
| Danger | ANSI `1` red, bold | `unpushed`, `conflict`, `error`, `↑n` of an unpushed pin |
| Warn | ANSI `3` yellow | `missing`, `off pin`, `changed`, differing checkout SHA |
| Info | ANSI `4` blue | `staged` |
| Info | ANSI `4` blue | `staged`, the `●` staged marker |
| Primary | ANSI `6` cyan | branch names, commit hashes, spinner |
| Muted | ANSI `8` grey | quiet rows, directory headers, arrows, labels |
| Text | terminal default | normal text |
@@ -194,20 +206,22 @@ colors in components.
| Key | Behavior |
|---|---|
| `↑` / `k`, `↓` / `j` | Previous / next submodule; directory headers are skipped |
| `↑` / `k`, `↓` / `j` | Previous / next submodule; directory headers are skipped. `↑` from the first submodule selects the header (superproject) |
| `space` | Toggle expand/collapse of the selected submodule |
| `→` / `l` | Collapsed with children: expand and select the first child. Expanded: select the first child. Leaf: nothing |
| `←` / `h` | Expanded: collapse it (stays selected). Otherwise: collapse the parent and select it. Top level: nothing |
| `→` / `l` | Header selected: first submodule. Collapsed with children: expand and select the first child. Expanded: select the first child. Leaf: nothing |
| `←` / `h` | Expanded: collapse it (stays selected). Otherwise: collapse the parent and select it. Top level: select the header (superproject) |
| `L` / `shift+→` / `ctrl+→` | Expand all; then follow the first child from the selection down to a submodule without children |
| `H` / `shift+←` / `ctrl+←` | Collapse all (resets remembered expansion); select the top-level ancestor |
| `home` / `g`, `end` / `G` | First / last submodule |
| `home` / `g`, `end` / `G` | Header (superproject) / last submodule |
| `pgup`, `pgdn` | Move by one screen |
| `?` | Toggle full help |
| `q`, `ctrl+c` | Quit |
| `f` | Fetch all repositories (does nothing while a fetch runs) |
| `r` | Reload everything (e.g. after editing files) |
| `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 |
| `u`, `b`, `/`, `!` | Planned actions (see requirements) |
| `/` | 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).
@@ -216,35 +230,97 @@ Collapsing an ancestor of the selection moves the selection to that ancestor.
The view scrolls so the selection is always visible; when the first submodule
is selected, headers above it are shown too.
### Filter
`/` shows a text input above the footer (`/ term`). While it is open the
tree shows only the submodules whose path (which includes the name)
contains the term, ignoring case; their ancestors stay as context and are
shown expanded, whatever the user folded. Directory headers without a
match vanish. If nothing matches the tree says `no submodule matches`.
- **Selection.** If the selected submodule is filtered out, the next
visible one (in display order) is selected, else the previous one; if none
is left, nothing is selected (the detail pane is empty).
- **Typing.** The input has the focus: letters edit the term, so `j`, `q`
etc. are text. `↑` `↓` `pgup` `pgdn` still move the selection. `enter`
hands the keys back to the tree (the filter stays, all keys work again);
`/` edits the term again.
- **`esc`** closes the input and removes the filter, in both modes. The
selection stays; its ancestors are expanded so it is still visible.
- The superproject is never filtered out; if nothing matches it is selected.
- The filter line shows the keys that apply on its right.
- The header counts are not filtered.
### Problems only
`!` reduces the tree to what needs attention. It uses the filter's rules
(matches with their ancestors, shown expanded, see [Filter](#filter)), with
"match" meaning *has an issue* (`Status.Issues()`) instead of *path contains
the term*.
- A submodule that is still loading or failed to load (`error`) counts as a
problem, since its state is unknown (as in the hidden counts, D7). Healthy
submodules vanish when their status arrives.
- A staged pin is not an issue and does not show the row.
- It combines with the filter (both must match). Unlike the filter it is a
mode, not an input: it stays until `!` is pressed again, also after `esc`
closed the filter.
- A line above the footer shows `problems only` (yellow) and `! show all`.
While the filter line is open, its hint starts with `problems only (!)`
instead.
- With no problem the tree says `no problems`.
- The selection follows the filter's rule: if it stops being visible (also
when a status changes), the next visible submodule is selected, else the
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 |
|---|---|
| the submodule has a staged pin | yellow `Restore src/app to`, `s staged pin · c committed pin (unstages it)`, right-aligned `any other key cancels` |
| 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 a
question is open. After `c` the name reads `src/app to the committed pin`. 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, and whether it works. Update this
table when a key is added, moved or implemented. "Bar" is the short footer
Every key in the UI, where it is shown. Update this
table when a key is added or moved. "Bar" is the short footer
bar, "Palette" the detail pane's palette (the footer takes its keys over
where the palette can't be shown, see [Key bar and palette](#key-bar-and-palette)),
"Help" the full help (`?`).
| Key | Action | Group | Shown in | Implemented |
|---|---|---|---|---|
| `↑` / `k`, `↓` / `j` | Move selection | Navigation | Bar, Help | yes |
| `←` / `h`, `→` / `l` | Fold / unfold, parent / first child | Navigation | Bar, Help | yes |
| `H` / `⇧←`, `L` / `⇧→` | Collapse / expand all | Navigation | Bar, Help | yes |
| `space` | Toggle expand/collapse | Navigation | Bar, Help | yes |
| `home` / `g`, `end` / `G` | First / last submodule | Navigation | Help | yes |
| `pgup`, `pgdn` | Move by one screen | Navigation | Help | yes |
| `c` | Copy path | Submodule | Palette, Help | yes |
| `u` | Checkout pin | Submodule | Help | no (flashes "not implemented") |
| `b` | Bump pin to tip | Submodule | Help | no (flashes "not implemented") |
| `f` | Fetch all repositories | Global | Help | yes |
| `r` | Reload everything | Global | Help | yes |
| `/` | Filter | Global | Help | no (flashes "not implemented") |
| `!` | Problems only | Global | Help | no (flashes "not implemented") |
| `?` | Toggle full help | Global | Bar, Help | yes |
| `q`, `ctrl+c` | Quit | Global | Bar, Help | yes |
| Key | Action | Group | Shown in |
|---|---|---|---|
| `↑` / `k`, `↓` / `j` | Move selection | Navigation | Bar, Help |
| `←` / `h`, `→` / `l` | Fold / unfold, parent / first child | Navigation | Bar, Help |
| `H` / `⇧←`, `L` / `⇧→` | Collapse / expand all | Navigation | Bar, Help |
| `space` | Toggle expand/collapse | Navigation | Bar, Help |
| `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 |
| `!` | Problems only (toggle) | Global | Help |
| `?` | Toggle full help | Global | Bar, Help |
| `q`, `ctrl+c` | Quit | Global | Bar, Help |
### Mouse
- Click a row: select it.
- Click a row: select it. Click the header: select the superproject.
- Click `▸` / `▾`: select and toggle.
- Scroll wheel: move the selection.
+1
View File
@@ -12,6 +12,7 @@ require (
)
require (
github.com/atotto/clipboard v0.1.4 // indirect
github.com/charmbracelet/colorprofile v0.4.1 // indirect
github.com/charmbracelet/x/cellbuf v0.0.15 // indirect
github.com/charmbracelet/x/term v0.2.2 // indirect
+2
View File
@@ -1,3 +1,5 @@
github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
github.com/aymanbagabas/go-udiff v0.3.1 h1:LV+qyBQ2pqe0u42ZsUEtPiCaUoqgA9gYRDs3vj1nolY=
+7 -1
View File
@@ -139,7 +139,13 @@ func TestWatch(t *testing.T) {
// a commit in another shell
git(t, filepath.Join(super, "libs", "store"), "commit", "-q", "--allow-empty", "-m", "Local")
if c := next(t, ch); c != (submodule.Change{Path: "libs/store", Local: true}) {
c := next(t, ch)
if c == (submodule.Change{Path: "src/net"}) {
// git fetch writes FETCH_HEAD and the refs at different moments, so
// with a 20ms poll it can be reported in two rounds
c = next(t, ch)
}
if c != (submodule.Change{Path: "libs/store", Local: true}) {
t.Errorf("after commit: %+v, want local change of libs/store", c)
}
+94 -17
View File
@@ -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
@@ -85,6 +87,13 @@ func (p *Provider) Tree(ctx context.Context) (*submodule.Repo, error) {
}
}
repo.Root = &submodule.Submodule{Name: filepath.Base(p.root), Path: ""}
if remote, err := remoteName(ctx, p.root); err != nil {
return nil, err
} else if remote != "" {
repo.Root.URL, _, _ = try(ctx, p.root, "config", "--get", "remote."+remote+".url")
}
mods := map[string]info{}
subs, err := p.build(ctx, p.root, nil, mods)
if err != nil {
@@ -141,6 +150,9 @@ func (p *Provider) Status(ctx context.Context, path string) (*submodule.Status,
case <-ctx.Done():
return nil, ctx.Err()
}
if path == "" {
return p.rootStatus(ctx)
}
p.mu.RLock()
in, ok := p.mods[path]
p.mu.RUnlock()
@@ -208,17 +220,79 @@ func status(ctx context.Context, in info) (*submodule.Status, error) {
return nil, err
}
if err := pinState(ctx, in, st, pin, head, branch, nil); err != nil {
return nil, err
}
return st, nil
}
// rootStatus describes the superproject. Its "pin" is HEAD, compared with the
// upstream of the current branch; submodules are ignored, their rows show
// them.
func (p *Provider) rootStatus(ctx context.Context) (*submodule.Status, error) {
in := info{superDir: p.root, dir: p.root}
st := &submodule.Status{Root: true}
if gitDir, _, err := gitDirs(in.dir); err == nil {
if fi, err := os.Stat(filepath.Join(gitDir, "FETCH_HEAD")); err == nil {
st.FetchedAt = fi.ModTime()
}
}
head, err := run(ctx, in.dir, "rev-parse", "HEAD")
if err != nil {
return nil, err
}
branch, onBranch, err := try(ctx, in.dir, "symbolic-ref", "--short", "-q", "HEAD")
if err != nil {
return nil, err
}
if !onBranch {
st.Checkout = submodule.CheckoutDetached
}
out, err := run(ctx, in.dir, "status", "--porcelain=v2", "-z", "--untracked-files=normal", "--ignore-submodules=all")
if err != nil {
return nil, err
}
entries := parseStatus(out)
// a leftover of --ignore-submodules=all in older git versions would
// report the submodules the rows already show
entries = slices.DeleteFunc(entries, func(e entry) bool { return e.submodule })
st.Worktree = worktreeOf(entries)
if st.Changes, err = changes(ctx, in.dir, entries); err != nil {
return nil, err
}
// the root is compared with its upstream, so that the header and the
// detail pane agree; only without one the closest branch is looked for
var chosen *candidate
if branch != "" {
if up, ok, err := try(ctx, in.dir, "rev-parse", "--abbrev-ref", "--symbolic-full-name", branch+"@{upstream}"); err != nil {
return nil, err
} else if ok {
chosen = &candidate{up, submodule.RefFromCurrentBranch}
}
}
if err := pinState(ctx, in, st, head, head, branch, chosen); err != nil {
return nil, err
}
return st, nil
}
// pinState fills the fields of st that describe pin: whether it is pushed, the
// ref it resolves to (chosen, if not nil), the commits and the distance to the
// ref. head is the checked-out commit.
func pinState(ctx context.Context, in info, st *submodule.Status, pin, head, branch string, chosen *candidate) error {
// the pin must exist locally to say anything about it
if _, ok, err := try(ctx, in.dir, "cat-file", "-e", pin+"^{commit}"); err != nil {
return nil, err
return err
} else if !ok {
return nil, fmt.Errorf("pinned commit %.7s not fetched", pin)
return fmt.Errorf("pinned commit %.7s not fetched", pin)
}
// remote branches containing the pin, oldest tip first
out, err = run(ctx, in.dir, "for-each-ref", "--contains", pin, "--sort=committerdate", "--format=%(refname)", "refs/remotes")
out, err := run(ctx, in.dir, "for-each-ref", "--contains", pin, "--sort=committerdate", "--format=%(refname)", "refs/remotes")
if err != nil {
return nil, err
return err
}
var containing []string
for _, r := range strings.Fields(out) {
@@ -228,44 +302,47 @@ func status(ctx context.Context, in info) (*submodule.Status, error) {
}
st.Pin.Pushed = len(containing) > 0
ref, source, err := resolve(ctx, in, pin, branch, containing)
if err != nil {
return nil, err
var ref string
var source submodule.RefSource
if chosen != nil {
ref, source = chosen.ref, chosen.source
} else if ref, source, err = resolve(ctx, in, pin, branch, containing); err != nil {
return err
}
st.Pin.Ref, st.Pin.Source = ref, source
// commits
if st.Pinned, err = commit(ctx, in.dir, pin); err != nil {
return nil, err
return err
}
st.CheckedOut = st.Pinned
if head != pin {
if st.CheckedOut, err = commit(ctx, in.dir, head); err != nil {
return nil, err
return err
}
}
if source == submodule.RefFromSHA {
st.Pin.Ref = st.Pinned.SHA
return st, nil
return nil
}
if st.Tip, err = commit(ctx, in.dir, ref); err != nil {
return nil, err
return err
}
out, err = run(ctx, in.dir, "rev-list", "--left-right", "--count", pin+"..."+ref)
if err != nil {
return nil, err
return err
}
if st.Pin.Ahead, st.Pin.Behind, err = parseLeftRight(out); err != nil {
return nil, err
return err
}
if st.Pin.Ahead+st.Pin.Behind > 0 {
out, err = run(ctx, in.dir, "log", commitFormat, fmt.Sprintf("--max-count=%d", maxBetween), pin+"..."+ref)
if err != nil {
return nil, err
return err
}
st.Between = parseCommits(out)
}
return st, nil
return nil
}
// resolve finds the ref that describes the pin, following the order in
+468
View File
@@ -0,0 +1,468 @@
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, to submodule.RestoreTarget) (submodule.Loss, error) {
dir, target, err := p.restoreTarget(ctx, path, to)
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. Staged pins are kept. With
// RestoreCommitted, a submodule's own staged pin is unstaged first. A missing
// submodule is cloned and missing commits are fetched; git never prompts.
func (p *Provider) Restore(ctx context.Context, path string, to submodule.RestoreTarget) 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 := pinOf(ctx, in, to)
if err != nil {
return err
}
if to == submodule.RestoreCommitted {
if _, err := p.exec(ctx, in.superDir, nil, "update-index", "--cacheinfo", "160000,"+pin+","+in.relPath); 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 pin to. The directory is "" if the
// submodule is not initialized.
func (p *Provider) restoreTarget(ctx context.Context, path string, to submodule.RestoreTarget) (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 := pinOf(ctx, in, to)
if err != nil {
return "", "", err
}
if !initialized(in.dir) {
return "", pin, nil
}
return in.dir, pin, nil
}
// pinOf returns the gitlink of a submodule in its parent's index (staged)
// or HEAD (committed).
func pinOf(ctx context.Context, in info, to submodule.RestoreTarget) (string, error) {
if to == submodule.RestoreCommitted {
out, _, err := try(ctx, in.superDir, "ls-tree", "HEAD", "--", in.relPath)
if err != nil {
return "", err
}
if pin := parseGitlink(out); pin != "" {
return pin, nil
}
return "", errors.New("has no committed pin (added, but not committed yet)")
}
out, err := run(ctx, in.superDir, "ls-files", "-s", "--", in.relPath)
if err != nil {
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 its index records. Branches are never
// moved: a checkout that is not on target is detached first. If the checkout
// stays on target, its staged pins are kept and are the submodules' targets.
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)
}
var keep []gitlink
if head, err := g("rev-parse", "--verify", "-q", "HEAD"); err != nil || head != sha {
if _, err := g("checkout", "--force", "--detach", sha); err != nil {
return err
}
} else if keep, err = stagedPins(ctx, dir); err != nil {
return err
}
if _, err := g("reset", "--hard", "-q"); err != nil {
return err
}
for _, l := range keep {
if _, err := g("update-index", "--cacheinfo", "160000,"+l.sha+","+l.path); 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 := indexGitlinks(ctx, dir)
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
}
// stagedPins lists the gitlinks of the index of dir that differ from HEAD's,
// i.e. staged pins of submodules that HEAD has. A submodule that was added
// but not committed is not one: its .gitmodules entry is not kept either.
func stagedPins(ctx context.Context, dir string) ([]gitlink, error) {
index, err := indexGitlinks(ctx, dir)
if err != nil {
return nil, err
}
tree, err := treeGitlinks(ctx, dir, "HEAD")
if err != nil {
return nil, err
}
committed := map[string]string{}
for _, l := range tree {
committed[l.path] = l.sha
}
var staged []gitlink
for _, l := range index {
if c, ok := committed[l.path]; ok && c != l.sha {
staged = append(staged, l)
}
}
return staged, 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()
}
}
// staged pins are kept, unless the checkout moves away from
// the commit they were staged on; then the submodules follow
// the target's tree
staged, err := stagedPins(ctx, dir)
if err != nil {
unknown()
}
var links []gitlink
switch {
case !moves:
links, err = indexGitlinks(ctx, dir)
case target != "":
own.StagedPins = len(staged)
links, err = treeGitlinks(ctx, dir, target)
default:
own.StagedPins = len(staged)
}
if err == nil && links != nil {
targets = map[string]string{}
for _, l := range links {
targets[l.path] = l.sha
}
}
}
if children, err = indexGitlinks(ctx, dir); err != nil {
unknown()
}
}()
if own.Files+own.Operations+own.Commits+own.Repositories+own.StagedPins+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
}
+334
View File
@@ -0,0 +1,334 @@
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 (staged ones included) and has no change
// besides staged pins.
func assertExact(t *testing.T, dir string) {
t.Helper()
if s := out(t, dir, "diff", "--name-only", "--ignore-submodules=dirty"); s != "" { // worktree vs index
t.Errorf("%s not clean:\n%s", dir, s)
}
if s := out(t, dir, "ls-files", "--others", "--exclude-standard"); s != "" {
t.Errorf("%s has untracked files:\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)
sub := filepath.Join(dir, f[1])
if s := out(t, sub, "diff", "--name-only", "--ignore-submodules=dirty"); s != "" {
t.Errorf("%s not clean:\n%s", f[1], s)
}
if s := out(t, sub, "ls-files", "--others", "--exclude-standard"); s != "" {
t.Errorf("%s has untracked files:\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, "", submodule.RestoreStaged); 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")
}
// the fixture stages libs/staged and libs/conflict
if got := out(t, super, "diff", "--cached", "--name-only"); got != "libs/conflict\nlibs/staged" {
t.Errorf("staged = %q, want the staged pins kept", got)
}
}
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", submodule.RestoreStaged); 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, submodule.RestoreStaged)
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, "", submodule.RestoreStaged); 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)
}
assertExact(t, super)
}
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", submodule.RestoreStaged); 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", submodule.RestoreStaged)
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(), "", submodule.RestoreStaged); err != nil {
t.Fatal(err)
}
if !exists(filepath.Join(super, "libs", "missing", ".git")) {
t.Error("libs/missing not initialized")
}
assertExact(t, super)
}
func TestRestoreKeepsNestedStagedPinAndResetsToIt(t *testing.T) {
p, root := ownFixture(t)
super := filepath.Join(root, "super")
log := filepath.Join(super, "libs", "log")
nested := filepath.Join(log, "modules", "nested")
// stage a new pin of nested in libs/log, then change nested again
git(t, nested, "commit", "-q", "-am", "Nested work")
staged := out(t, nested, "rev-parse", "HEAD")
git(t, log, "add", "modules/nested")
os.WriteFile(filepath.Join(nested, "README"), []byte("more\n"), 0o644)
if l, err := p.RestoreLoss(context.Background(), "", submodule.RestoreStaged); err != nil || l.StagedPins != 0 || l.Files == 0 {
t.Fatalf("loss %+v, %v: want the change, not the staged pin", l, err)
}
if err := p.Restore(context.Background(), "", submodule.RestoreStaged); err != nil {
t.Fatal(err)
}
if got := out(t, log, "diff", "--cached", "--name-only"); got != "modules/nested" {
t.Errorf("libs/log staged = %q, want the nested pin", got)
}
if got := out(t, nested, "rev-parse", "HEAD"); got != staged {
t.Errorf("nested HEAD = %s, want the staged pin %s", got, staged)
}
assertExact(t, super)
}
func TestRestoreCommittedUnstagesThePin(t *testing.T) {
p, root := ownFixture(t)
super := filepath.Join(root, "super")
committed := strings.Fields(out(t, super, "ls-tree", "HEAD", "libs/staged"))[2]
if err := p.Restore(context.Background(), "libs/staged", submodule.RestoreCommitted); err != nil {
t.Fatal(err)
}
if got := out(t, super, "diff", "--cached", "--name-only"); strings.Contains(got, "libs/staged") {
t.Errorf("still staged: %q", got)
}
if got := out(t, filepath.Join(super, "libs", "staged"), "rev-parse", "HEAD"); got != committed {
t.Errorf("HEAD = %s, want the committed pin %s", got, committed)
}
}
func TestRestoreLossCountsStagedPinsOfAMovingRepository(t *testing.T) {
p, root := ownFixture(t)
super := filepath.Join(root, "super")
log := filepath.Join(super, "libs", "log")
nested := filepath.Join(log, "modules", "nested")
// libs/log moves on (off pin, on its branch) and stages a nested pin
git(t, nested, "commit", "-q", "-am", "Nested work")
git(t, log, "add", "modules/nested")
git(t, log, "commit", "-q", "--allow-empty", "-m", "Log work")
git(t, nested, "commit", "-q", "--allow-empty", "-m", "More nested work")
git(t, log, "add", "modules/nested")
l, err := p.RestoreLoss(context.Background(), "libs/log", submodule.RestoreStaged)
if err != nil {
t.Fatal(err)
}
// the nested commits are on a detached HEAD that moves back: lost too
if l.StagedPins != 1 || l.Commits != 2 {
t.Errorf("loss %+v, want 1 staged pin and the 2 detached nested commits", l)
}
}
+172
View File
@@ -0,0 +1,172 @@
package git
import (
"context"
"os"
"path/filepath"
"slices"
"testing"
"submarine/internal/submodule"
)
// rootFixture is ownFixture with a bare origin for the superproject, main
// pushed and tracking it. It returns the provider, the fixture root and the
// path of the bare repository.
func rootFixture(t *testing.T) (*Provider, string, string) {
t.Helper()
p, root := ownFixture(t)
bare := filepath.Join(root, "remotes", "super.git")
git(t, root, "init", "-q", "--bare", bare)
git(t, p.root, "remote", "add", "origin", bare)
git(t, p.root, "push", "-q", "-u", "origin", "main")
return p, root, bare
}
func rootStatus(t *testing.T, p *Provider) *submodule.Status {
t.Helper()
st, err := p.Status(context.Background(), "")
if err != nil {
t.Fatal(err)
}
if !st.Root {
t.Error("Status.Root = false for the superproject")
}
return st
}
func issueKinds(st *submodule.Status) []submodule.IssueKind {
var kinds []submodule.IssueKind
for _, is := range st.Issues() {
kinds = append(kinds, is.Kind)
}
return kinds
}
func TestTreeRoot(t *testing.T) {
p, root, bare := rootFixture(t)
repo, err := p.Tree(context.Background())
if err != nil {
t.Fatal(err)
}
if r := repo.Root; r == nil || !r.IsRoot() || r.Name != "super" || r.URL != bare || len(r.Children) != 0 {
t.Errorf("Root = %+v (fixture %s)", r, root)
}
}
// The fixture has dirty, conflicting and staged submodules; none of them
// makes the superproject itself changed, their rows show them.
func TestRootStatusIgnoresSubmodules(t *testing.T) {
p, _, _ := rootFixture(t)
st := rootStatus(t, p)
if kinds := issueKinds(st); len(kinds) != 0 {
t.Errorf("issues = %v, want none", kinds)
}
if st.Changes != nil || st.PinStaged || st.Presence != submodule.PresenceOK {
t.Errorf("Changes = %+v, PinStaged = %v", st.Changes, st.PinStaged)
}
if st.Pin.Ref != "origin/main" || st.Pin.Source != submodule.RefFromCurrentBranch || !st.Pin.Pushed {
t.Errorf("Pin = %+v", st.Pin)
}
if st.Pinned == nil || st.Pinned != st.CheckedOut && *st.Pinned != *st.CheckedOut {
t.Errorf("Pinned = %+v, CheckedOut = %+v, want HEAD for both", st.Pinned, st.CheckedOut)
}
}
func TestRootStatusUnpushed(t *testing.T) {
p, _, _ := rootFixture(t)
git(t, p.root, "commit", "--allow-empty", "-m", "Local work")
st := rootStatus(t, p)
if kinds := issueKinds(st); !slices.Equal(kinds, []submodule.IssueKind{submodule.IssueUnpushed}) {
t.Errorf("issues = %v, want unpushed", kinds)
}
if st.Pin.Ahead != 1 || st.Pin.Behind != 0 {
t.Errorf("ahead/behind = %d/%d, want 1/0", st.Pin.Ahead, st.Pin.Behind)
}
if d := st.Issues()[0].Detail; d != "HEAD not pushed to any remote" {
t.Errorf("detail = %q", d)
}
}
// A submodule the current branch no longer has stays on disk after a branch
// switch (its .git is a file into .git/modules). It is an untracked
// repository of the superproject.
func TestRootStatusLeftoverSubmodule(t *testing.T) {
p, _, _ := rootFixture(t)
git(t, p.root, "switch", "-q", "-c", "nosub")
git(t, p.root, "rm", "-q", "--cached", "src/ui")
git(t, p.root, "commit", "-q", "-m", "Drop src/ui")
git(t, p.root, "switch", "-q", "main")
git(t, p.root, "switch", "-q", "nosub")
if _, err := os.Stat(filepath.Join(p.root, "src", "ui", ".git")); err != nil {
t.Fatalf("src/ui should be left behind: %v", err)
}
st := rootStatus(t, p)
if kinds := issueKinds(st); !slices.Equal(kinds, []submodule.IssueKind{submodule.IssueUnpushed, submodule.IssueChanged}) {
t.Fatalf("issues = %v, want unpushed (new branch) and changed", kinds)
}
if d := st.Issues()[1].Detail; d != "untracked repository" {
t.Errorf("detail = %q, want untracked repository", d)
}
if st.Changes == nil || len(st.Changes.Files) != 1 {
t.Fatalf("Changes = %+v", st.Changes)
}
if f := st.Changes.Files[0]; f.Path != "src/ui/" || f.Kind != submodule.ChangeUntracked || !f.Repo {
t.Errorf("file = %+v", f)
}
}
func TestRootStatusModified(t *testing.T) {
p, _, _ := rootFixture(t)
if err := os.WriteFile(filepath.Join(p.root, "README"), []byte("edited\n"), 0o644); err != nil {
t.Fatal(err)
}
st := rootStatus(t, p)
if kinds := issueKinds(st); !slices.Equal(kinds, []submodule.IssueKind{submodule.IssueChanged}) {
t.Errorf("issues = %v, want changed", kinds)
}
if st.Worktree != submodule.WorktreeModified {
t.Errorf("Worktree = %v", st.Worktree)
}
}
// The ref of the superproject is the upstream of its branch, even if another
// remote branch is closer, so that it agrees with the header's ahead/behind.
func TestRootStatusRefIsUpstream(t *testing.T) {
p, root, bare := rootFixture(t)
other := filepath.Join(root, "other")
git(t, root, "clone", "-q", bare, other)
git(t, other, "switch", "-q", "-c", "feat")
git(t, other, "commit", "--allow-empty", "-m", "Feature")
git(t, other, "push", "-q", "origin", "feat")
git(t, other, "switch", "-q", "main")
for range 3 {
git(t, other, "commit", "--allow-empty", "-m", "Upstream")
}
git(t, other, "push", "-q", "origin", "main")
git(t, p.root, "fetch", "-q")
st := rootStatus(t, p)
if st.Pin.Ref != "origin/main" || st.Pin.Source != submodule.RefFromCurrentBranch {
t.Errorf("ref = %q (%v), want origin/main via the current branch", st.Pin.Ref, st.Pin.Source)
}
if st.Pin.Ahead != 0 || st.Pin.Behind != 3 {
t.Errorf("ahead/behind = %d/%d, want 0/3", st.Pin.Ahead, st.Pin.Behind)
}
if len(st.Between) != 3 {
t.Errorf("Between = %d commits, want 3", len(st.Between))
}
}
func TestRootStatusDetached(t *testing.T) {
p, _, _ := rootFixture(t)
git(t, p.root, "switch", "-q", "--detach")
st := rootStatus(t, p)
if st.Checkout != submodule.CheckoutDetached {
t.Errorf("Checkout = %v, want detached", st.Checkout)
}
if !st.Pin.Pushed {
t.Error("detached HEAD on a pushed commit should be pushed")
}
}
+28
View File
@@ -42,8 +42,11 @@ func (p *Provider) Tree(ctx context.Context) (*submodule.Repo, error) {
repo := &submodule.Repo{
Path: "~/coding/my_project",
Branch: "main",
Behind: 2,
FetchedAt: time.Now().Add(-2 * time.Minute),
}
repo.Root = &submodule.Submodule{Name: "my_project", URL: "git@github.com:acme/my_project.git"}
p.statuses[""] = rootStatus()
repo.Submodules = p.build(fixture(), nil)
return repo, nil
}
@@ -154,6 +157,31 @@ func changes(age time.Duration, files []submodule.FileChange) *submodule.Changes
return c
}
// rootStatus is a superproject that is behind its upstream and still has a
// clone of a submodule the current branch no longer has.
func rootStatus() *submodule.Status {
tip := commit("e4f5a6b", "Move docs into a submodule", "anna", 3*time.Hour)
head := commit("0a1b2c3", "Update README", "ben", 2*24*time.Hour)
st := &submodule.Status{
Root: true,
Worktree: submodule.WorktreeModified,
Pin: submodule.PinInfo{Ref: "origin/main", Behind: 2, Pushed: true, Source: submodule.RefFromCurrentBranch},
Pinned: head,
CheckedOut: head,
Tip: tip,
Between: []submodule.Commit{
*tip,
*commit("9d8c7b6", "Drop the legacy module", "chris", 20*time.Hour),
},
FetchedAt: now.Add(-2 * time.Minute),
}
st.Changes = changes(15*time.Minute, []submodule.FileChange{
{Path: "README.md", Kind: submodule.ChangeModified, Added: 4, Removed: 1},
{Path: "old_module/", Kind: submodule.ChangeUntracked, Repo: true},
})
return st
}
func fixture() []node {
c1 := commit("3f9a1c2", "Bump golangci-lint to v2", "anna", 26*time.Hour)
c2 := commit("a1b2c3d", "Initial import", "ben", 40*24*time.Hour)
+5
View File
@@ -16,6 +16,7 @@ type Repo struct {
Ahead int
Behind int
FetchedAt time.Time
Root *Submodule // the superproject as a selectable node; Path is "", no Children
Submodules []*Submodule
}
@@ -32,6 +33,9 @@ type Submodule struct {
FetchErr error // set if the last fetch failed; not an issue
}
// IsRoot reports whether s is the superproject itself.
func (s *Submodule) IsRoot() bool { return s != nil && s.Path == "" }
// Presence describes whether the submodule exists on disk.
type Presence int
@@ -171,6 +175,7 @@ func (c *Changes) Sort() {
// Status is the full state of one submodule.
type Status struct {
Root bool // the superproject: the "pin" is its HEAD
Presence Presence
Checkout Checkout
Worktree Worktree
+47
View File
@@ -33,6 +33,53 @@ 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, to RestoreTarget) (Loss, error)
Restore(ctx context.Context, path string, to RestoreTarget) error
}
// RestoreTarget is the pin a submodule is restored to. It doesn't matter for
// the superproject, whose target is always HEAD.
type RestoreTarget int
const (
RestoreStaged RestoreTarget = iota // the pin in the parent's index: a staged pin is kept and used
RestoreCommitted // the pin in the parent's HEAD: a staged pin is unstaged
)
// 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
StagedPins int // staged pins of repositories that move to another commit
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,
StagedPins: l.StagedPins + o.StagedPins,
Unknown: l.Unknown + o.Unknown,
}
}
// Change reports that the git metadata of a repository changed.
type Change struct {
Path string // "" is the superproject
+19 -7
View File
@@ -43,7 +43,11 @@ func (s *Status) Issues() []Issue {
var out []Issue
if !s.Pin.Pushed {
out = append(out, Issue{IssueUnpushed, LevelDanger, "pin not pushed to any remote"})
detail := "pin not pushed to any remote"
if s.Root {
detail = "HEAD not pushed to any remote"
}
out = append(out, Issue{IssueUnpushed, LevelDanger, detail})
}
if s.Worktree == WorktreeConflict {
out = append(out, Issue{IssueConflict, LevelDanger, "merge conflict"})
@@ -60,21 +64,29 @@ func (s *Status) Issues() []Issue {
return out
}
// Hidden counts issues below a submodule per level.
type Hidden struct{ Warn, Danger int }
// Hidden counts issues below a submodule per level, plus staged pins (which
// are not issues).
type Hidden struct{ Warn, Danger, Staged int }
// Total is the number of issues of all levels.
// Total is the number of issues of all levels; staged pins are not counted.
func (h Hidden) Total() int { return h.Warn + h.Danger }
// HiddenIssues counts the issues of all loaded descendants per level. A
// HiddenIssues counts the issues and staged pins of all loaded descendants. A
// descendant whose load failed counts as one danger, since its state is
// unknown.
func (m *Submodule) HiddenIssues() Hidden {
func (m *Submodule) HiddenIssues() Hidden { return HiddenIn(m.Children) }
// HiddenIn counts the issues and staged pins of mods and all their loaded
// descendants, like HiddenIssues.
func HiddenIn(mods []*Submodule) Hidden {
var h Hidden
Walk(m.Children, func(c *Submodule) {
Walk(mods, func(c *Submodule) {
if c.Status == nil && c.Err != nil {
h.Danger++
}
if c.Status != nil && c.Status.PinStaged {
h.Staged++
}
for _, is := range c.Status.Issues() {
switch is.Level {
case LevelDanger:
+14 -3
View File
@@ -39,15 +39,15 @@ func TestIssues(t *testing.T) {
func TestHiddenIssues(t *testing.T) {
ok := &Status{Pin: PinInfo{Pushed: true}}
dirty := &Status{Worktree: WorktreeModified, Pin: PinInfo{Pushed: true}}
dirty := &Status{Worktree: WorktreeModified, Pin: PinInfo{Pushed: true}, PinStaged: true}
unpushed := &Status{}
grandchild := &Submodule{Status: unpushed}
child := &Submodule{Status: dirty, Children: []*Submodule{grandchild}}
loading := &Submodule{}
root := &Submodule{Status: ok, Children: []*Submodule{child, {Status: ok}, loading}}
if got := root.HiddenIssues(); got != (Hidden{Warn: 1, Danger: 1}) {
t.Errorf("HiddenIssues() = %+v; want 1 warn, 1 danger", got)
if got := root.HiddenIssues(); got != (Hidden{Warn: 1, Danger: 1, Staged: 1}) {
t.Errorf("HiddenIssues() = %+v; want 1 warn, 1 danger, 1 staged", got)
}
}
@@ -78,3 +78,14 @@ func TestHiddenIssuesCountLoadErrors(t *testing.T) {
t.Errorf("got %+v; want 1 danger", got)
}
}
func TestRootIssueWording(t *testing.T) {
st := &Status{Root: true}
got := st.Issues()
if len(got) != 1 || got[0].Kind != IssueUnpushed || got[0].Level != LevelDanger || got[0].Detail != "HEAD not pushed to any remote" {
t.Errorf("Issues() = %+v", got)
}
if !(&Submodule{}).IsRoot() || (&Submodule{Path: "a"}).IsRoot() {
t.Error("IsRoot should be true exactly for the empty path")
}
}
+152 -24
View File
@@ -4,10 +4,12 @@ package tui
import (
"context"
"strings"
"time"
"github.com/charmbracelet/bubbles/key"
"github.com/charmbracelet/bubbles/spinner"
"github.com/charmbracelet/bubbles/textinput"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
zone "github.com/lrstanley/bubblezone"
@@ -27,17 +29,11 @@ const (
detailWidthRatio = 0.4
)
// Options configure the application.
type Options struct {
Fetch bool // fetch all repositories in the background on startup
}
type Model struct {
ctx context.Context
provider submodule.Provider
fetcher submodule.Fetcher // nil if the provider can't fetch
watcher submodule.Watcher // nil if the provider can't watch
opts Options
theme theme.Theme
keys keyMap
@@ -55,19 +51,31 @@ type Model struct {
detail detail.Model
footer footer.Model
spinner spinner.Model
filter textinput.Model
// filtering is true while the filter line is shown; the filter applies
// 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
}
func New(ctx context.Context, p submodule.Provider, opts Options) Model {
func New(ctx context.Context, p submodule.Provider) Model {
t := theme.Default()
k := defaultKeys()
sp := spinner.New(spinner.WithSpinner(spinner.MiniDot))
fi := textinput.New()
fi.Prompt = "/ "
fi.PromptStyle = theme.Fg(t.Primary)
fi.Cursor.Style = theme.Fg(t.Primary)
fi.Placeholder = "filter by name or path"
fi.PlaceholderStyle = theme.Fg(t.Muted)
m := Model{
ctx: ctx,
provider: p,
opts: opts,
theme: t,
keys: k,
loads: newReloader(),
@@ -76,6 +84,7 @@ func New(ctx context.Context, p submodule.Provider, opts Options) Model {
detail: detail.New(t, k.Submodule()),
footer: footer.New(t, k),
spinner: sp,
filter: fi,
}
m.fetcher, _ = p.(submodule.Fetcher)
m.watcher, _ = p.(submodule.Watcher)
@@ -150,10 +159,10 @@ 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 == restoreChecking || m.restore.phase == restoreRunning)) {
return true
}
loading := false
loading := m.repo.Root != nil && m.repo.Root.Status == nil && m.repo.Root.Err == nil
submodule.Walk(m.repo.Submodules, func(s *submodule.Submodule) {
loading = loading || (s.Status == nil && s.Err == nil)
})
@@ -186,6 +195,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
} else {
s.Status, s.Err = msg.status, nil
}
m.tree.Refit()
}
if m.loads.done(msg.path) {
return m, m.loadStatus(msg.path)
@@ -195,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 {
@@ -254,7 +270,7 @@ func (m Model) treeLoaded(msg treeLoadedMsg) (tea.Model, tea.Cmd) {
m.byPath = map[string]*submodule.Submodule{}
reload := initial || m.reloadStatuses
m.reloadStatuses = false
submodule.Walk(m.repo.Submodules, func(s *submodule.Submodule) {
track := func(s *submodule.Submodule) {
m.byPath[s.Path] = s
if o, ok := old[s.Path]; ok {
s.Status, s.Err, s.FetchErr = o.Status, o.Err, o.FetchErr
@@ -262,7 +278,12 @@ func (m Model) treeLoaded(msg treeLoadedMsg) (tea.Model, tea.Cmd) {
if reload || s.Status == nil {
cmds = append(cmds, m.reloadStatus(s.Path))
}
})
}
if m.repo.Root != nil {
track(m.repo.Root)
}
submodule.Walk(m.repo.Submodules, track)
m.tree.SetRoot(m.repo.Root)
if initial {
m.tree.SetRoots(m.repo.Submodules)
@@ -270,9 +291,6 @@ func (m Model) treeLoaded(msg treeLoadedMsg) (tea.Model, tea.Cmd) {
m.changes = m.watcher.Watch(m.ctx)
cmds = append(cmds, m.waitForChange())
}
if m.opts.Fetch {
cmds = append(cmds, m.startFetch())
}
} else {
m.tree.ReplaceRoots(m.repo.Submodules)
}
@@ -292,6 +310,10 @@ func (m Model) changed(msg changeMsg) (tea.Model, tea.Cmd) {
// superproject: pins of all submodules may have moved (local), or
// only its header (remote); .gitmodules: the structure changed
cmds = append(cmds, m.reloadTree(c.Local))
if c.Path == "" {
// the root's own state: files, HEAD, and (for a fetch) its upstream
cmds = append(cmds, m.reloadStatus(""))
}
default:
s, ok := m.byPath[c.Path]
if !ok {
@@ -309,7 +331,24 @@ 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 {
switch m.restore.phase {
case restoreChoosing:
return m.choosePin(msg)
case restoreAsking:
return m.answerRestore(msg)
}
}
if m.notice != "" {
m.notice = ""
m.layout()
}
if m.filter.Focused() {
return m.typeFilter(msg)
}
switch {
case m.filtering && msg.Type == tea.KeyEsc:
m.closeFilter()
case key.Matches(msg, k.Quit):
return m, tea.Quit
case key.Matches(msg, k.Help):
@@ -355,14 +394,89 @@ func (m Model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
return m, m.reloadTree(true)
case key.Matches(msg, k.Copy):
return m.copyPath()
case key.Matches(msg, k.Update, k.Bump, k.Filter, k.Issues):
cmd := m.footer.Flash("“" + msg.String() + "” is not implemented in the prototype")
case key.Matches(msg, k.Filter):
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()
}
return m, nil
}
// typeFilter handles a key while the filter input has the focus: text edits
// the filter, enter hands the keys back to the tree (the filter stays), esc
// closes the filter. Arrows still move the selection.
func (m Model) typeFilter(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
switch msg.Type {
case tea.KeyCtrlC:
return m, tea.Quit
case tea.KeyEsc:
m.closeFilter()
case tea.KeyEnter:
m.filter.Blur()
case tea.KeyUp:
m.tree.Up()
case tea.KeyDown:
m.tree.Down()
case tea.KeyPgUp:
m.tree.PageUp()
case tea.KeyPgDown:
m.tree.PageDown()
default:
var cmd tea.Cmd
m.filter, cmd = m.filter.Update(msg)
m.tree.SetFilter(m.filter.Value())
return m, cmd
}
return m, nil
}
// closeFilter hides the filter line and removes the filter; the selection stays.
func (m *Model) closeFilter() {
m.filtering = false
m.filter.Blur()
m.filter.Reset()
m.tree.SetFilter("")
m.layout()
}
// filterView is the line above the footer: the input and, right-aligned, the
// keys that apply.
func (m Model) filterView() string {
hint := "enter navigate • esc close"
if !m.filter.Focused() {
hint = "/ edit • esc close"
}
if m.tree.ProblemsOnly() {
hint = "problems only (!) • " + hint
}
hint = theme.Fg(m.theme.Muted).Render(hint)
m.filter.Width = max(m.width-lipgloss.Width(hint)-6, 1)
in := m.filter.View()
gap := max(m.width-2-lipgloss.Width(in)-lipgloss.Width(hint), 1)
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: 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.
func (m Model) problemsView() string {
left := theme.Fg(m.theme.Warn).Render("problems only")
right := theme.Fg(m.theme.Muted).Render("! show all")
gap := max(m.width-2-lipgloss.Width(left)-lipgloss.Width(right), 1)
return lipgloss.NewStyle().Width(m.width).Padding(0, 1).Render(left + strings.Repeat(" ", gap) + right)
}
// copyPath copies the absolute directory of the selected submodule to the
// clipboard, or its path in the superproject if the provider can't tell.
func (m Model) copyPath() (tea.Model, tea.Cmd) {
@@ -371,6 +485,9 @@ func (m Model) copyPath() (tea.Model, tea.Cmd) {
return m, nil
}
path := s.Path
if s.IsRoot() && m.repo != nil {
path = m.repo.Path
}
if l, ok := m.provider.(submodule.Locator); ok {
if dir := l.Dir(s.Path); dir != "" {
path = dir
@@ -384,7 +501,11 @@ func (m Model) copyPath() (tea.Model, tea.Cmd) {
// layout distributes the terminal size among the components.
func (m *Model) layout() {
m.footer.SetWidth(m.width)
bodyHeight := max(m.height-headerHeight-lipgloss.Height(m.footer.View()), 0)
bodyHeight := m.height - headerHeight - lipgloss.Height(m.footer.View())
if m.extraLine() {
bodyHeight--
}
bodyHeight = max(bodyHeight, 0)
m.showDetail = m.width >= detailHideBelow
treeWidth := m.width
@@ -411,13 +532,20 @@ func (m Model) View() string {
Border(lipgloss.NormalBorder(), false, false, false, true).
BorderForeground(m.theme.Border).
PaddingLeft(1).
Render(m.detail.View(m.tree.Selected()))
Render(m.detail.View(m.tree.Selected(), m.repo))
body = lipgloss.JoinHorizontal(lipgloss.Top, body, pane)
}
return zone.Scan(lipgloss.JoinVertical(lipgloss.Left,
header.View(m.theme, m.repo, m.fetch, m.spinner.View(), m.width),
body,
m.footer.View(),
))
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.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())
}
return zone.Scan(lipgloss.JoinVertical(lipgloss.Left, append(parts, m.footer.View())...))
}
+69 -30
View File
@@ -40,13 +40,15 @@ func (m Model) PaletteVisible() bool { return m.height >= paletteMinHeight }
func (m *Model) SetSize(w, h int) { m.width, m.height = w, h }
func (m *Model) SetSpinner(frame string) { m.spinner = frame }
func (m Model) View(s *submodule.Submodule) string {
// View renders s; repo is the loaded superproject (nil while loading), which
// the superproject's own view needs for its path and submodule summary.
func (m Model) View(s *submodule.Submodule, repo *submodule.Repo) string {
if m.width <= 0 || m.height <= 0 {
return ""
}
var lines []string
if s != nil {
lines = m.lines(s)
lines = m.lines(s, repo)
}
for i, l := range lines {
lines[i] = ansi.Truncate(l, m.width, "…")
@@ -68,12 +70,17 @@ func (m Model) View(s *submodule.Submodule) string {
return body + "\n" + palette
}
func (m Model) lines(s *submodule.Submodule) []string {
func (m Model) lines(s *submodule.Submodule, repo *submodule.Repo) []string {
t := m.theme
muted, text := theme.Fg(t.Muted), theme.Fg(t.Text)
label := func(l string) string { return muted.Width(labelWidth).Render(l) }
dir := path.Dir(s.Path)
root := s.IsRoot() && repo != nil
full := s.Path
if root {
full = repo.Path
}
dir := path.Dir(full)
if dir == "." {
dir = ""
} else {
@@ -97,14 +104,18 @@ func (m Model) lines(s *submodule.Submodule) []string {
for _, is := range issues {
out = append(out, t.LevelStyle(is.Level, lipgloss.NewStyle()).Width(labelWidth).Render(theme.IssueWord(is.Kind))+text.Render(is.Detail))
}
if len(issues) == 0 {
if len(issues) == 0 && !st.PinStaged {
out = append(out, label(theme.InSync)+muted.Render("nothing to do"))
}
if st.PinStaged {
out = append(out, theme.Fg(t.Info).Width(labelWidth).Render("staged")+text.Render("pin change staged, not committed"))
out = append(out, theme.Fg(t.Info).Width(labelWidth).Render(theme.Staged)+text.Render("pin change staged, not committed"))
}
if st.Checkout == submodule.CheckoutDetached {
out = append(out, label("")+muted.Render("detached HEAD on pin"))
text := "detached HEAD on pin"
if st.Root {
text = "detached HEAD"
}
out = append(out, label("")+muted.Render(text))
}
if st.Changes != nil {
out = append(out, "")
@@ -141,15 +152,19 @@ func (m Model) lines(s *submodule.Submodule) []string {
}
return label(name) + shaStyle.Render(c.SHA) + " " + text.Render(c.Subject)
}
out = append(out,
commitLine("Pinned", st.Pinned, false),
commitLine("Checkout", st.CheckedOut, st.Checkout == submodule.CheckoutDiffers),
commitLine("Tip", st.Tip, false),
)
if st.Root {
out = append(out, commitLine("HEAD", st.Pinned, false), commitLine("Tip", st.Tip, false))
} else {
out = append(out,
commitLine("Pinned", st.Pinned, false),
commitLine("Checkout", st.CheckedOut, st.Checkout == submodule.CheckoutDiffers),
commitLine("Tip", st.Tip, false),
)
}
// difference between pin and tip
if len(st.Between) > 0 {
out = append(out, "", text.Render(m.betweenTitle(st.Pin)))
out = append(out, "", text.Render(m.betweenTitle(st.Pin, st.Root)))
for _, c := range st.Between {
out = append(out,
" "+theme.Fg(t.Primary).Render(c.SHA)+" "+text.Render(c.Subject),
@@ -160,26 +175,50 @@ func (m Model) lines(s *submodule.Submodule) []string {
// children & meta
out = append(out, "")
if n := len(s.Children); n > 0 {
if root {
n := 0
submodule.Walk(repo.Submodules, func(*submodule.Submodule) { n++ })
summary := fmt.Sprintf("%d", n)
if parts := m.hiddenParts(submodule.HiddenIn(repo.Submodules)); parts != "" {
summary += muted.Render(" · ") + parts
}
out = append(out, label("Modules")+summary)
} else if n := len(s.Children); n > 0 {
children := fmt.Sprintf("%d direct", n)
h := s.HiddenIssues()
var parts []string
if h.Danger > 0 {
parts = append(parts, t.LevelStyle(submodule.LevelDanger, lipgloss.NewStyle()).Render(fmt.Sprintf("%d error(s)", h.Danger)))
}
if h.Warn > 0 {
parts = append(parts, t.LevelStyle(submodule.LevelWarn, lipgloss.NewStyle()).Render(fmt.Sprintf("%d warning(s)", h.Warn)))
}
if len(parts) > 0 {
children += muted.Render(" · ") + strings.Join(parts, muted.Render(", ")) + muted.Render(" below")
if parts := m.hiddenParts(s.HiddenIssues()); parts != "" {
children += muted.Render(" · ") + parts + muted.Render(" below")
}
out = append(out, label("Children")+children)
}
out = append(out, label("URL")+muted.Render(s.URL))
if s.URL != "" {
out = append(out, label("URL")+muted.Render(s.URL))
}
return out
}
func (m Model) betweenTitle(p submodule.PinInfo) string {
// hiddenParts words issue counts like "1 error(s), 2 warning(s)"; empty if
// there are none.
func (m Model) hiddenParts(h submodule.Hidden) string {
t := m.theme
var parts []string
if h.Danger > 0 {
parts = append(parts, t.LevelStyle(submodule.LevelDanger, lipgloss.NewStyle()).Render(fmt.Sprintf("%d error(s)", h.Danger)))
}
if h.Warn > 0 {
parts = append(parts, t.LevelStyle(submodule.LevelWarn, lipgloss.NewStyle()).Render(fmt.Sprintf("%d warning(s)", h.Warn)))
}
if h.Staged > 0 {
parts = append(parts, theme.Fg(t.Info).Render(fmt.Sprintf("%d %s", h.Staged, theme.Staged)))
}
return strings.Join(parts, theme.Fg(t.Muted).Render(", "))
}
// betweenTitle words the commit list; root says HEAD instead of Pin.
func (m Model) betweenTitle(p submodule.PinInfo, root bool) string {
subject := "Pin"
if root {
subject = "HEAD"
}
plural := func(n int) string {
if n == 1 {
return "commit"
@@ -188,11 +227,11 @@ func (m Model) betweenTitle(p submodule.PinInfo) string {
}
switch {
case p.Ahead > 0 && p.Behind > 0:
return fmt.Sprintf("Pin and %s have diverged (%s%d %s%d):",
p.Ref, m.theme.Glyphs.Ahead, p.Ahead, m.theme.Glyphs.Behind, p.Behind)
return fmt.Sprintf("%s and %s have diverged (%s%d %s%d):",
subject, p.Ref, m.theme.Glyphs.Ahead, p.Ahead, m.theme.Glyphs.Behind, p.Behind)
case p.Ahead > 0:
return fmt.Sprintf("Pin is %d %s ahead of %s:", p.Ahead, plural(p.Ahead), p.Ref)
return fmt.Sprintf("%s is %d %s ahead of %s:", subject, p.Ahead, plural(p.Ahead), p.Ref)
default:
return fmt.Sprintf("Pin is %d %s behind %s:", p.Behind, plural(p.Behind), p.Ref)
return fmt.Sprintf("%s is %d %s behind %s:", subject, p.Behind, plural(p.Behind), p.Ref)
}
}
+9 -2
View File
@@ -21,9 +21,13 @@ type Fetch struct {
}
// View renders repo info, state counts and fetch state on one line. spinner
// is the current spinner frame.
func View(t theme.Theme, repo *submodule.Repo, fetch Fetch, spinner string, width int) string {
// is the current spinner frame. The header stands for the superproject: with
// selected it is drawn like a selected tree row.
func View(t theme.Theme, repo *submodule.Repo, fetch Fetch, spinner string, width int, selected bool) string {
bar := lipgloss.NewStyle().Background(t.Bar)
if selected {
bar = lipgloss.NewStyle().Background(t.Selected).Bold(true)
}
seg := func(c lipgloss.TerminalColor, s string) string { return bar.Foreground(c).Render(s) }
if repo == nil {
@@ -34,6 +38,9 @@ func View(t theme.Theme, repo *submodule.Repo, fetch Fetch, spinner string, widt
if d := t.Delta(submodule.PinInfo{Ahead: repo.Ahead, Behind: repo.Behind, Pushed: true}, bar); d != "" {
left += seg(t.Text, " ") + d
}
if repo.Root != nil {
left += seg(t.Text, " ") + t.StatusWord(repo.Root, bar, spinner)
}
// count submodules per issue kind (most severe first), plus staged pins
var total, loading, errored, staged int
+8 -9
View File
@@ -3,11 +3,11 @@ package tui
import "github.com/charmbracelet/bubbles/key"
type keyMap struct {
Up, Down, Left, Right, Toggle key.Binding
CollapseAll, ExpandAll key.Binding
Home, End, PageUp, PageDown key.Binding
Update, Bump, Fetch, Reload, Filter, Issues key.Binding
Copy, Help, Quit key.Binding
Up, Down, Left, Right, Toggle key.Binding
CollapseAll, ExpandAll key.Binding
Home, End, PageUp, PageDown key.Binding
Fetch, Reload, Filter, Issues key.Binding
Copy, Restore, Help, Quit key.Binding
}
func defaultKeys() keyMap {
@@ -23,12 +23,11 @@ func defaultKeys() keyMap {
End: key.NewBinding(key.WithKeys("end", "G"), key.WithHelp("end/G", "last")),
PageUp: key.NewBinding(key.WithKeys("pgup"), key.WithHelp("pgup", "page up")),
PageDown: key.NewBinding(key.WithKeys("pgdown"), key.WithHelp("pgdn", "page down")),
Update: key.NewBinding(key.WithKeys("u"), key.WithHelp("u", "checkout pin")),
Bump: key.NewBinding(key.WithKeys("b"), key.WithHelp("b", "bump pin to tip")),
Fetch: key.NewBinding(key.WithKeys("f"), key.WithHelp("f", "fetch")),
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")),
@@ -45,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} }
@@ -59,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.Update, k.Bump},
{k.Copy, k.Restore},
{k.Fetch, k.Reload, k.Filter, k.Issues},
{k.Help, k.Quit},
}
+257
View File
@@ -0,0 +1,257 @@
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 (
restoreChoosing restorePhase = iota // staged or committed pin?
restoreChecking // 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
to submodule.RestoreTarget
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. A submodule with a
// staged pin first asks which pin to restore to.
func (m Model) startRestore() (tea.Model, tea.Cmd) {
s := m.tree.Selected()
_, 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)}
if !s.IsRoot() && s.Status != nil && s.Status.PinStaged {
m.restore.phase = restoreChoosing
m.layout()
return m, nil
}
return m.checkRestore()
}
// checkRestore reads what the restore would discard.
func (m Model) checkRestore() (tea.Model, tea.Cmd) {
m.restore.phase = restoreChecking
m.layout()
r := m.provider.(submodule.Restorer)
ctx, path, to := m.ctx, m.restore.path, m.restore.to
check := func() tea.Msg {
loss, err := r.RestoreLoss(ctx, path, to)
return lossMsg{path, loss, err}
}
return m, tea.Batch(check, m.tick())
}
// choosePin handles a key while the restore asks for the pin: s the staged
// one, c the committed one (which unstages it), anything else cancels.
func (m Model) choosePin(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
switch {
case msg.Type == tea.KeyCtrlC:
return m, tea.Quit
case msg.String() == "s":
m.restore.to = submodule.RestoreStaged
return m.checkRestore()
case msg.String() == "c":
m.restore.to = submodule.RestoreCommitted
return m.checkRestore()
}
m.restore = nil
m.layout()
return m, nil
}
// 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, to := m.ctx, m.restore.path, m.restore.to
run := func() tea.Msg { return restoreDoneMsg{path, r.Restore(ctx, path, to)} }
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.toText()
}
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...)
}
// toText is " to the committed pin" if that was chosen; the staged pin is the
// usual target and needs no words.
func (r *restoreState) toText() string {
if r.to == submodule.RestoreCommitted {
return " to the committed pin"
}
return ""
}
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 := "", ""
key := func(k string) string { return theme.Fg(m.theme.Text).Bold(true).Render(k) }
muted := func(s string) string { return theme.Fg(m.theme.Muted).Render(s) }
switch r.phase {
case restoreChoosing:
left = theme.Fg(m.theme.Warn).Render("Restore "+r.name+" to") + " " +
key("s") + muted(" staged pin · ") + key("c") + muted(" committed pin (unstages it)")
right = muted("any other key cancels")
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+r.toText()+"…")
case restoreAsking:
left = theme.Fg(m.theme.Warn).Render("Restore "+r.name+r.toText()+"?") + " " +
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.StagedPins > 0 {
parts = append(parts, plural(l.StagedPins, "staged pin", "staged pins"))
}
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)
}
+189
View File
@@ -0,0 +1,189 @@
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"},
{submodule.Loss{StagedPins: 2, Modules: 1}, "2 staged pins"},
}
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
to []submodule.RestoreTarget
}
func (f *fakeRestorer) RestoreLoss(context.Context, string, submodule.RestoreTarget) (submodule.Loss, error) {
return f.loss, nil
}
func (f *fakeRestorer) Restore(_ context.Context, path string, to submodule.RestoreTarget) error {
f.restored = append(f.restored, path)
f.to = append(f.to, to)
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")
}
}
func TestRestoreOfAStagedPinAsksWhichPin(t *testing.T) {
m, f := newRestoreModel(t, submodule.Loss{})
mm := m.(Model)
var staged *submodule.Submodule
submodule.Walk(mm.repo.Submodules, func(s *submodule.Submodule) {
if staged == nil && s.Children == nil {
staged = s
}
})
staged.Status = &submodule.Status{PinStaged: true, Pin: submodule.PinInfo{Pushed: true}}
mm.tree.Select(staged.Path)
if mm.tree.Selected() != staged {
t.Fatalf("setup: selected %v", mm.tree.Selected())
}
m = mm
m, _ = press(m, "x")
if m.(Model).restore == nil || m.(Model).restore.phase != restoreChoosing {
t.Fatalf("want the pin question, got %+v", m.(Model).restore)
}
m, cmd := press(m, "c")
var next tea.Cmd
for _, msg := range run(cmd) {
if l, ok := msg.(lossMsg); ok {
m, next = m.Update(l)
}
}
feed(m, next)
if len(f.to) != 1 || f.to[0] != submodule.RestoreCommitted || f.restored[0] != staged.Path {
t.Fatalf("restored %q to %v, want %s to the committed pin", f.restored, f.to, staged.Path)
}
}
+29
View File
@@ -12,6 +12,10 @@ import (
// InSync is the word for a submodule without issues.
const InSync = "in sync"
// Staged is the word for a pin change staged in the superproject. It is not
// an issue: it replaces InSync, or follows an issue word as a marker.
const Staged = "staged"
// IssueWord is the short label for an issue kind shown in the tree.
func IssueWord(k submodule.IssueKind) string {
switch k {
@@ -28,6 +32,31 @@ func IssueWord(k submodule.IssueKind) string {
}
}
// StatusWord renders the state of s as one word: a spinner while loading,
// `error`, the most severe issue with "+n" for further ones, `staged`, or a
// muted "in sync". base is applied to every segment, e.g. to keep a row
// background.
func (t Theme) StatusWord(s *submodule.Submodule, base lipgloss.Style, spinner string) string {
seg := func(c lipgloss.TerminalColor, text string) string { return base.Foreground(c).Render(text) }
switch {
case s.Err != nil:
return t.LevelStyle(submodule.LevelDanger, base).Render("error")
case s.Status == nil:
return seg(t.Primary, spinner)
}
if issues := s.Status.Issues(); len(issues) > 0 {
out := t.LevelStyle(issues[0].Level, base).Render(IssueWord(issues[0].Kind))
if len(issues) > 1 {
out += seg(t.Muted, fmt.Sprintf(" +%d", len(issues)-1))
}
return out
}
if s.Status.PinStaged {
return seg(t.Info, Staged)
}
return seg(t.Muted, InSync)
}
// ChangeLetter is the one-letter label of a changed file.
func ChangeLetter(k submodule.ChangeKind) string {
switch k {
+188
View File
@@ -0,0 +1,188 @@
package tree
import (
"strings"
"submarine/internal/submodule"
)
// SetFilter shows only the submodules whose path contains term (ignoring
// case), plus their ancestors, which are shown expanded. An empty term
// removes the filter. It combines with SetProblemsOnly.
//
// If the selected submodule is filtered out, the next visible one is
// selected (the previous one if there is none after it, none if nothing
// matches). Removing the filter keeps the selection visible by expanding its
// ancestors.
func (m *Model) SetFilter(term string) {
term = strings.ToLower(term)
if term == m.filter {
return
}
old := m.cursor
m.filter = term
m.narrow(old)
}
// SetProblemsOnly shows only the submodules with issues, plus their
// ancestors, which are shown expanded. A submodule whose status is loading or
// failed to load counts as a problem, since its state is unknown. The
// selection behaves as with SetFilter.
func (m *Model) SetProblemsOnly(on bool) {
if on == m.problemsOnly {
return
}
old := m.cursor
m.problemsOnly = on
m.narrow(old)
}
// ProblemsOnly reports whether only submodules with issues are shown.
func (m Model) ProblemsOnly() bool { return m.problemsOnly }
// Refit keeps the selection visible after statuses changed, which can make a
// submodule enter or leave the problems-only view.
func (m *Model) Refit() {
if m.problemsOnly {
m.reselect(m.cursor)
m.clampOffset()
}
}
// narrow fixes the selection after the filter or the problems-only view
// changed.
func (m *Model) narrow(old *submodule.Submodule) {
if m.narrowed() {
m.reselect(old)
} else {
for p := parentOf(old); p != nil; p = p.Parent {
m.expanded[p.Path] = true
}
if m.cursor == nil {
m.cursor = m.firstNode()
}
}
m.clampOffset()
}
// Filter returns the active filter term in lower case.
func (m Model) Filter() string { return m.filter }
// narrowed reports whether the tree is reduced to matches.
func (m Model) narrowed() bool { return m.filter != "" || m.problemsOnly }
func parentOf(s *submodule.Submodule) *submodule.Submodule {
if s == nil {
return nil
}
return s.Parent
}
// reselect makes sure the cursor is on a visible submodule. If old is not
// visible any more, the closest visible one after it in display order
// becomes the selection, else the closest one before it.
func (m *Model) reselect(old *submodule.Submodule) {
if old != nil && old == m.root { // the superproject is never filtered out
m.cursor = old
return
}
visible := map[*submodule.Submodule]bool{}
var first *submodule.Submodule
for _, r := range m.rows() {
if r.kind == rowNode {
visible[r.node] = true
if first == nil {
first = r.node
}
}
}
if old != nil && visible[old] {
m.cursor = old
return
}
m.cursor = first
if first == nil {
m.cursor = m.root // nothing matches: the superproject remains
}
if old == nil || first == nil {
return
}
var all []row
m.appendAll(&all, m.roots, 0)
i := 0
for ; i < len(all); i++ {
if all[i].node == old {
break
}
}
for j := i + 1; j < len(all); j++ {
if visible[all[j].node] {
m.cursor = all[j].node
return
}
}
for j := i - 1; j >= 0; j-- {
if visible[all[j].node] {
m.cursor = all[j].node
return
}
}
}
// appendAll lists every submodule in display order, whatever is expanded or
// filtered.
func (m *Model) appendAll(out *[]row, mods []*submodule.Submodule, depth int) {
for _, g := range groupByDir(mods) {
for _, s := range g.mods {
*out = append(*out, row{kind: rowNode, depth: depth, node: s})
m.appendAll(out, s.Children, depth+1)
}
}
}
// isExpanded reports whether the children of s are shown. While narrowed,
// that is the case if a match is below s, whatever the user expanded.
func (m *Model) isExpanded(s *submodule.Submodule) bool {
if !m.narrowed() {
return m.expanded[s.Path]
}
for _, c := range s.Children {
if m.shown(c) {
return true
}
}
return false
}
// filtered returns the submodules of mods that pass the filter.
func (m *Model) filtered(mods []*submodule.Submodule) []*submodule.Submodule {
if !m.narrowed() {
return mods
}
var out []*submodule.Submodule
for _, s := range mods {
if m.shown(s) {
out = append(out, s)
}
}
return out
}
// shown reports whether s matches the filter or has a descendant that does.
func (m *Model) shown(s *submodule.Submodule) bool {
if strings.Contains(strings.ToLower(s.Path), m.filter) && (!m.problemsOnly || hasProblem(s)) {
return true
}
for _, c := range s.Children {
if m.shown(c) {
return true
}
}
return false
}
// hasProblem reports whether s has an issue or an unknown state (loading, or
// failed to load, like HiddenIn counts it).
func hasProblem(s *submodule.Submodule) bool {
return s.Status == nil || len(s.Status.Issues()) > 0
}
+2 -2
View File
@@ -30,7 +30,7 @@ func (m *Model) rows() []row {
// appendRows groups mods by the directory of their RelPath. Each group with a
// directory gets a header row; its submodules are indented one level deeper.
func (m *Model) appendRows(out *[]row, mods []*submodule.Submodule, depth int) {
for _, g := range groupByDir(mods) {
for _, g := range groupByDir(m.filtered(mods)) {
d := depth
if g.dir != "." {
*out = append(*out, row{kind: rowDir, depth: depth, label: g.dir + "/"})
@@ -38,7 +38,7 @@ func (m *Model) appendRows(out *[]row, mods []*submodule.Submodule, depth int) {
}
for _, s := range g.mods {
*out = append(*out, row{kind: rowNode, depth: d, node: s})
if m.expanded[s.Path] && len(s.Children) > 0 {
if m.isExpanded(s) && len(s.Children) > 0 {
m.appendRows(out, s.Children, d+1)
}
}
+99 -34
View File
@@ -23,28 +23,39 @@ const (
type Model struct {
id string // zone prefix, unique to this instance
theme theme.Theme
root *submodule.Submodule // the superproject: selectable, above the first row; may be nil
roots []*submodule.Submodule
expanded map[string]bool // keyed by Submodule.Path; kept when ancestors collapse
cursor *submodule.Submodule
offset int // index of the first visible row
width int
height int
spinner string
filter string // lower-case; empty if not filtering
// problemsOnly hides submodules without issues; it combines with filter.
problemsOnly bool
cursor *submodule.Submodule
offset int // index of the first visible row
width int
height int
spinner string
}
func New(t theme.Theme) Model {
return Model{id: zone.NewPrefix(), theme: t, expanded: map[string]bool{}}
}
// SetRoots replaces the tree. All nodes start collapsed.
// SetRoot sets the superproject, which can be selected like a row above the
// first one. Call it before SetRoots / ReplaceRoots.
func (m *Model) SetRoot(root *submodule.Submodule) { m.root = root }
// SetRoots replaces the tree. All nodes start collapsed. The superproject is
// selected if there is one, else the first submodule.
func (m *Model) SetRoots(roots []*submodule.Submodule) {
m.roots = roots
m.expanded = map[string]bool{}
m.cursor = nil
m.offset = 0
if len(roots) > 0 {
m.cursor = m.rows()[m.firstNodeRow()].node
if m.root != nil {
m.cursor = m.root
return
}
m.cursor = m.firstNode()
}
// ReplaceRoots swaps in a reloaded tree. Unlike SetRoots it keeps the
@@ -55,16 +66,20 @@ func (m *Model) ReplaceRoots(roots []*submodule.Submodule) {
if m.cursor != nil {
path = m.cursor.Path
}
wasRoot := m.cursor.IsRoot() // by path: SetRoot may have installed a new node
m.roots = roots
m.cursor = nil
if wasRoot && m.root != nil {
m.cursor = m.root
}
for _, r := range m.rows() {
if r.kind == rowNode && r.node.Path == path {
if m.cursor == nil && r.kind == rowNode && r.node.Path == path {
m.cursor = r.node
break
}
}
if m.cursor == nil && len(roots) > 0 {
m.cursor = m.rows()[m.firstNodeRow()].node
if m.cursor == nil {
m.reselect(nil)
}
m.clampOffset()
}
@@ -77,9 +92,19 @@ func (m *Model) SetSize(w, h int) {
// SetSpinner sets the frame drawn for submodules that are still loading.
func (m *Model) SetSpinner(frame string) { m.spinner = frame }
// Selected returns the submodule under the cursor, or nil.
// Selected returns the submodule under the cursor, or nil. It is the
// superproject (empty Path) while that is selected.
func (m Model) Selected() *submodule.Submodule { return m.cursor }
// RootSelected reports whether the superproject is selected.
func (m Model) RootSelected() bool { return m.onRoot() }
func (m Model) onRoot() bool { return m.root != nil && m.cursor == m.root }
// RootZone is the zone mark of the superproject's display; a click on it
// selects the superproject. The caller wraps the header in it.
func (m Model) RootZone() string { return m.id + "root" }
// --- navigation --------------------------------------------------------------
// Up selects the previous submodule row.
@@ -105,6 +130,10 @@ func (m *Model) Toggle() {
// Right expands the selected submodule and selects its first child.
func (m *Model) Right() {
if m.onRoot() {
m.Down()
return
}
if m.cursor == nil || len(m.cursor.Children) == 0 {
return
}
@@ -121,6 +150,9 @@ func (m *Model) Left() {
m.setExpanded(m.cursor, false)
case m.cursor.Parent != nil:
m.setExpanded(m.cursor.Parent, false)
case m.root != nil && !m.onRoot():
m.cursor = m.root // the superproject is the parent of the top level
m.offset = 0
}
}
@@ -180,24 +212,37 @@ func isAncestor(a, s *submodule.Submodule) bool {
}
// moveBy moves the cursor by n submodule rows, skipping directory headers
// and stopping at the ends.
// and stopping at the ends. The superproject counts as a row before the
// first one.
func (m *Model) moveBy(n int) {
rows := m.rows()
i := m.cursorRow(rows)
if i < 0 {
if m.onRoot() {
i = -1
} else if i < 0 {
return
}
step := 1
if n < 0 {
step, n = -1, -n
}
for j := i + step; n > 0 && j >= 0 && j < len(rows); j += step {
if rows[j].kind == rowNode {
for j := i + step; n > 0 && j >= -1 && j < len(rows); j += step {
switch {
case j == -1:
if m.root != nil {
i = -1
n--
}
case rows[j].kind == rowNode:
i = j
n--
}
}
m.cursor = rows[i].node
if i < 0 {
m.cursor = m.root
} else {
m.cursor = rows[i].node
}
m.clampOffset()
}
@@ -210,6 +255,15 @@ func (m Model) cursorRow(rows []row) int {
return -1
}
func (m *Model) firstNode() *submodule.Submodule {
for _, r := range m.rows() {
if r.kind == rowNode {
return r.node
}
}
return nil
}
func (m Model) firstNodeRow() int {
for i, r := range m.rows() {
if r.kind == rowNode {
@@ -225,6 +279,10 @@ func (m *Model) clampOffset() {
if m.height <= 0 {
return
}
if m.onRoot() {
m.offset = 0
return
}
rows := m.rows()
i := m.cursorRow(rows)
if i < 0 {
@@ -261,6 +319,11 @@ func (m *Model) Update(msg tea.Msg) {
if mm.Action != tea.MouseActionPress || mm.Button != tea.MouseButtonLeft {
return
}
if m.root != nil && zone.Get(m.RootZone()).InBounds(mm) {
m.cursor = m.root
m.offset = 0
return
}
for _, r := range m.visibleRows() {
if r.kind != rowNode {
continue
@@ -304,6 +367,13 @@ func (m Model) View() string {
lines = append(lines, m.nodeLine(r, nameColW, refW))
}
}
if len(rows) == 0 && m.narrowed() {
none := " no submodule matches"
if m.filter == "" {
none = " no problems"
}
lines = append(lines, theme.Fg(m.theme.Muted).Width(m.width).Render(none))
}
for len(lines) < m.height {
lines = append(lines, strings.Repeat(" ", m.width))
}
@@ -324,7 +394,7 @@ func (m Model) nameColumnWidth(rows []row, refW int) int {
}
func (m Model) childCount(s *submodule.Submodule) string {
if len(s.Children) == 0 || m.expanded[s.Path] {
if len(s.Children) == 0 || m.isExpanded(s) {
return ""
}
return fmt.Sprintf(" [%d]", len(s.Children))
@@ -368,7 +438,7 @@ func (m Model) nodeLine(r row, nameColW, refW int) string {
}
pad := func(n int) string { return base.Render(strings.Repeat(" ", max(n, 0))) }
collapsed := len(s.Children) > 0 && !m.expanded[s.Path]
collapsed := len(s.Children) > 0 && !m.isExpanded(s)
// toggle marker
marker := " "
@@ -399,22 +469,17 @@ func (m Model) nodeLine(r row, nameColW, refW int) string {
nameCell := seg(textColor, name) + seg(t.Muted, count) + pad(nameW-lipgloss.Width(name+count))
// status: the most severe issue as a word, "+n" for further issues;
// a muted "in sync" without issues, so an empty cell never looks broken
var status string
switch {
case s.Err != nil:
status = t.LevelStyle(submodule.LevelDanger, base).Render("error")
case s.Status == nil:
status = seg(t.Primary, m.spinner)
case len(issues) > 0:
status = t.LevelStyle(issues[0].Level, base).Render(theme.IssueWord(issues[0].Kind))
if len(issues) > 1 {
status += seg(t.Muted, fmt.Sprintf(" +%d", len(issues)-1))
}
default:
status = seg(t.Muted, theme.InSync)
// a muted "in sync" without issues, so an empty cell never looks broken.
// A staged pin is no issue: it replaces "in sync", or follows the issue
// as a blue marker (also for staged pins below a collapsed row).
staged := s.Status != nil && s.Status.PinStaged
h := s.HiddenIssues()
stagedMark := staged && len(issues) > 0
status := t.StatusWord(s, base, m.spinner)
if stagedMark || collapsed && h.Staged > 0 {
status += pad(1) + seg(t.Info, t.Glyphs.Staged)
}
if h := s.HiddenIssues(); collapsed && h.Total() > 0 {
if collapsed && h.Total() > 0 {
for _, c := range []struct {
level submodule.Level
n int
+242
View File
@@ -249,3 +249,245 @@ func TestExpandAllOnLeafKeepsSelection(t *testing.T) {
t.Fatal("all nodes with children should be expanded")
}
}
func TestFilterShowsMatchesWithAncestors(t *testing.T) {
m := newTree(t)
m.SetFilter("D")
want := []string{"src/", "b", "modules/", "c", "d"}
if got := visible(m); !equal(got, want) {
t.Fatalf("rows = %v, want %v", got, want)
}
assertCursor(t, m, "b") // a was selected and is gone: next available (an ancestor of the match)
}
func TestFilterSelectionMovesToNextThenPrevious(t *testing.T) {
m := newTree(t)
m.Select("a")
m.SetFilter("f")
assertCursor(t, m, "f")
m.SetFilter("")
m.Select("f")
m.SetFilter("a") // only a matches, it lies before f
assertCursor(t, m, "a")
}
func TestFilterWithoutMatchSelectsNothing(t *testing.T) {
m := newTree(t)
m.SetFilter("zzz")
if m.Selected() != nil || len(m.rows()) != 0 {
t.Fatalf("selected %v, rows %v", m.Selected(), visible(m))
}
m.Down() // must not panic
m.SetFilter("f")
assertCursor(t, m, "f")
}
func TestClearingFilterKeepsSelectionVisible(t *testing.T) {
m := newTree(t)
m.SetFilter("d")
m.Select("d")
m.SetFilter("")
assertCursor(t, m, "d")
want := []string{"a", "src/", "b", "modules/", "c", "d", "e", "f"}
if got := visible(m); !equal(got, want) {
t.Fatalf("rows = %v, want %v", got, want)
}
}
func equal(a, b []string) bool {
if len(a) != len(b) {
return false
}
for i := range a {
if a[i] != b[i] {
return false
}
}
return true
}
// newTreeWithRoot is newTree with a selectable superproject.
func newTreeWithRoot(t *testing.T) *Model {
t.Helper()
m := New(theme.Default())
m.SetRoot(&submodule.Submodule{Name: "root"})
m.SetRoots(fixture())
m.SetSize(80, 20)
return &m
}
func TestRootIsSelectedInitially(t *testing.T) {
m := newTreeWithRoot(t)
if !m.RootSelected() {
t.Fatal("root not selected initially")
}
assertCursor(t, m, "")
}
func TestRootIsAboveFirstRow(t *testing.T) {
m := newTreeWithRoot(t)
m.Down()
assertCursor(t, m, "a")
if m.RootSelected() {
t.Error("root still selected after Down")
}
m.Up()
if !m.RootSelected() {
t.Error("Up from the first submodule should select the root")
}
m.Up() // stays
if !m.RootSelected() {
t.Error("Up on the root should stay on the root")
}
m.End()
assertCursor(t, m, "f")
m.Home()
if !m.RootSelected() {
t.Error("Home should select the root")
}
m.PageDown()
assertCursor(t, m, "f")
m.PageUp()
if !m.RootSelected() {
t.Error("PageUp should reach the root")
}
}
func TestRootNavigationKeys(t *testing.T) {
m := newTreeWithRoot(t)
m.Right() // like Down
assertCursor(t, m, "a")
m.Left() // top level, nothing to collapse: the parent is the root
if !m.RootSelected() {
t.Error("Left on a top-level submodule should select the root")
}
m.Left()
m.Toggle()
m.CollapseAll()
m.ExpandAll()
if !m.RootSelected() {
t.Error("fold keys must not move the root selection")
}
m.Down()
m.Down() // b
m.Right()
m.Right() // d
m.CollapseAll()
assertCursor(t, m, "b")
}
func TestReplaceRootsKeepsRootSelected(t *testing.T) {
m := newTreeWithRoot(t)
newRoot := &submodule.Submodule{Name: "root"}
m.SetRoot(newRoot)
m.ReplaceRoots(fixture())
if !m.RootSelected() || m.Selected() != newRoot {
t.Error("root selection should move to the reloaded root")
}
m.Down()
m.SetRoot(&submodule.Submodule{Name: "root"})
m.ReplaceRoots(fixture())
assertCursor(t, m, "a")
}
func TestFilterKeepsRoot(t *testing.T) {
m := newTreeWithRoot(t)
m.SetFilter("f")
if !m.RootSelected() {
t.Error("the root stays selected while filtering")
}
m.Down()
assertCursor(t, m, "f")
m.SetFilter("zzz") // nothing matches: the root remains, not nil
if !m.RootSelected() {
t.Error("no match should fall back to the root")
}
m.SetFilter("")
if !m.RootSelected() {
t.Error("closing the filter should keep the root")
}
}
// healthy gives every submodule of the fixture an issue-free status, then
// marks the given paths as changed.
func healthy(t *testing.T, m *Model, changed ...string) {
t.Helper()
submodule.Walk(m.roots, func(s *submodule.Submodule) {
s.Status = &submodule.Status{Pin: submodule.PinInfo{Pushed: true}}
for _, p := range changed {
if s.Path == p {
s.Status.Worktree = submodule.WorktreeModified
}
}
})
}
func TestProblemsOnlyShowsIssuesWithAncestors(t *testing.T) {
m := newTree(t)
healthy(t, m, "d")
m.SetProblemsOnly(true)
want := []string{"src/", "b", "modules/", "c", "d"}
if got := visible(m); !equal(got, want) {
t.Fatalf("rows = %v, want %v", got, want)
}
assertCursor(t, m, "b") // a was selected and is healthy: next available
m.Select("d")
m.SetProblemsOnly(false)
assertCursor(t, m, "d")
if got := visible(m); len(got) != 8 {
t.Fatalf("rows = %v, want all expanded to d", got)
}
}
func TestProblemsOnlyKeepsUnknownStates(t *testing.T) {
m := newTree(t)
healthy(t, m)
f := m.roots[2]
f.Status, f.Err = nil, os.ErrNotExist // failed to load
m.roots[0].Status = nil // still loading
m.SetProblemsOnly(true)
if got, want := visible(m), []string{"a", "src/", "f"}; !equal(got, want) {
t.Fatalf("rows = %v, want %v", got, want)
}
}
func TestProblemsOnlyWithoutProblemsKeepsRoot(t *testing.T) {
m := New(theme.Default())
m.SetRoot(&submodule.Submodule{Status: &submodule.Status{Root: true}})
m.SetRoots(fixture())
m.SetSize(80, 20)
healthy(t, &m)
m.SetProblemsOnly(true)
if len(m.rows()) != 0 || !m.RootSelected() {
t.Fatalf("rows %v, root selected %v", visible(&m), m.RootSelected())
}
m.Down() // must not panic
}
func TestProblemsOnlyCombinesWithFilter(t *testing.T) {
m := newTree(t)
healthy(t, m, "d", "f")
m.SetProblemsOnly(true)
m.SetFilter("f")
if got, want := visible(m), []string{"src/", "f"}; !equal(got, want) {
t.Fatalf("rows = %v, want %v", got, want)
}
m.SetFilter("")
if !m.ProblemsOnly() || len(visible(m)) != 6 {
t.Fatalf("closing the filter keeps problems only: %v", visible(m))
}
}
func TestRefitMovesSelectionWhenProblemDisappears(t *testing.T) {
m := newTree(t)
healthy(t, m, "a", "f")
m.SetProblemsOnly(true)
assertCursor(t, m, "a")
m.roots[0].Status.Worktree = submodule.WorktreeClean
m.Refit()
assertCursor(t, m, "f")
}