9 Commits
Author SHA1 Message Date
simonfrankenandClaude Sonnet 5.5 01a9babe64 Add a release skill
Picks the next semver from the commits since the last tag, runs a release
check, tags and pushes, hands over to the developer for make release, then
creates the Gitea release with MCP.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-22 14:38:14 +02:00
simonfrankenandClaude Sonnet 5.5 648e1fd46c Support the mouse in the branch popup
A click on a branch switches to it, the wheel moves the selection, a click
outside closes the popup. The popup is hit-tested by its own position, as it
is drawn after zone.Scan.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-22 14:38:14 +02:00
simonfrankenandClaude Opus 5.5 2b6b781826 Add switch branch (b): stage the tip and restore to it
b opens a centered popup with a search line and the local and remote
branches of the selection. Enter stages the branch tip as the pin of a
submodule, restores it (and everything below) to it, and puts HEAD on the
branch; a remote branch gets a local tracking branch unless one exists
elsewhere. Works on the superproject too. Losses are asked like restore.
The backdrop behind the popup is dimmed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 14:18:49 +02:00
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
39 changed files with 4264 additions and 200 deletions
+158
View File
@@ -0,0 +1,158 @@
---
name: release
description: Prepare and publish a submarine release. Finds the latest release, picks the next semver from the changes since, runs a release check, creates and pushes the git tag, hands over to the developer for `make release`, then creates the Gitea release with MCP. Use only when the user asks to release.
disable-model-invocation: true
---
# Release submarine
A release is built on the developer's machine (`make release`, see
`docs/architecture.md#distribution`). `make release` publishes the binaries
of the **clean `vX.Y.Z` tag at HEAD** to the Gitea package registry. This
skill does everything around it. The developer runs `make release`
themselves, in the middle: you never run it and never read or print `.env`
or `GITEA_TOKEN`.
Pushing a tag and creating a release are outward-facing and hard to undo.
Do them only after the confirmation in step 5, and never force-push, move or
delete a tag without asking.
Gitea: owner `simonfranken`, repo `submarine`, `https://git.simon-franken.de`.
## 1. Preconditions
Stop and tell the user, if any of these fails:
```sh
git fetch origin --tags
git branch --show-current # main
git status --porcelain # empty: make release refuses a dirty tree
git rev-parse HEAD origin/main # equal: HEAD is pushed, nothing to release from a local-only commit
git tag --points-at HEAD # empty: HEAD is not released yet
grep -q '^GITEA_TOKEN=.\+' .env # exists: only test it, never print it
```
## 2. Latest release
```sh
git tag --list 'v[0-9]*.[0-9]*.[0-9]*' --sort=-v:refname | head -1
```
Cross-check with the `mcp__gitea__get_latest_release` MCP tool (load it with
ToolSearch first). If the tag and the Gitea release differ (a tag without a
release is normal for the one in progress, a release without a tag is not),
tell the user and ask before going on. With no tag at all, the first release
is `v0.1.0`.
## 3. Changes since
```sh
git log --format='%h %s%n%b---' <last>..HEAD
git diff --stat <last>..HEAD
```
No commits since the last tag: stop, there is nothing to release. Read the
commits, and where a subject is not clear, the diff. Sort each change as:
- **breaking**: removes or changes a key, a command or flag, the on-disk or
registry contract (`install.sh`, asset names, `submarine-latest/latest/VERSION`,
`submarine update`), or behavior users rely on;
- **feature**: a new key, action, view or option, or a visible behavior;
- **fix / other**: bug fixes, docs, refactoring, tests, performance.
## 4. Next version
| Changes include | Version `0.y.z` (current) | Version `≥ 1.0.0` |
|---|---|---|
| breaking | minor (`0.y+1.0`) | major |
| feature | minor (`0.y+1.0`) | minor |
| only fix / other | patch (`0.y.z+1`) | patch |
submarine is below 1.0: the first release at `1.0.0` is a decision for the
user, never made by this skill. Prepare a one-line reason per change and the
proposed version.
## 5. Release check (before anything is tagged)
All must pass; stop at the first failure and report its output. Nothing has
been pushed yet, so there is nothing to undo.
```sh
go vet ./...
go test ./...
test -z "$(gofmt -l .)"
make dist VERSION=vX.Y.Z # all four platforms cross-compile
./dist/submarine_linux_amd64 version # prints "submarine vX.Y.Z" (run the one for this machine)
make clean
```
Also check by reading: `README.md` and `docs/` mention the new behavior of
the features in the changelog (AGENTS.md: keep docs in sync). A missing doc
is a finding to report, not a reason to edit during a release; ask the user.
`make dist` and `make clean` only touch the git-ignored `dist/`.
## 6. Confirm, then tag and push
Ask the user (AskUserQuestion) to confirm the version, with the proposal
first and marked `(Recommended)`, the other bumps as alternatives. Show: the
last version, the changes with their category, the check result, and that
the next step creates and pushes the tag. Only after the answer:
```sh
git tag -a vX.Y.Z -m "vX.Y.Z"
git push origin vX.Y.Z
git ls-remote --tags origin vX.Y.Z # the tag is on the remote
```
If the push fails, report git's message. Do not retry with force. If the tag
is wrong after it was pushed, ask before deleting it.
## 7. Hand over: the developer runs `make release`
HEAD is now the clean tag, which is what `make release` needs. Tell the
developer to run, in the repository:
```sh
make release
```
(it reads `GITEA_TOKEN` from `.env`, tests, builds, uploads, and points
`submarine-latest` at the version last). **Stop here and wait** until they
say it finished. If it failed, help with the output; do not run it yourself.
## 8. Verify the upload, create the Gitea release
Once the developer reports success:
```sh
curl -fsS https://git.simon-franken.de/api/packages/simonfranken/generic/submarine-latest/latest/VERSION
```
must print `vX.Y.Z` (the registry is public; `install.sh` reads it the same
way). If it doesn't, the upload did not complete: do not create the release,
report it.
Then create the release with `mcp__gitea__create_release` (load it with
ToolSearch first):
- `owner`: `simonfranken`, `repo`: `submarine`
- `tag_name`: `vX.Y.Z`
- `target`: the tag's commit, `git rev-parse vX.Y.Z^{commit}`
- `title`: `vX.Y.Z`
- `is_draft`: false, `is_pre_release`: false
- `body`: release notes, see below
Release notes are for users of the tool, not a commit dump: short, grouped
under **Breaking changes** (only if any), **New**, **Fixes** (leave out empty
groups and pure refactoring/tests), each item one line naming the key or
behavior (`b` switches the branch of a repository...). End with:
```
Install or update: `curl -fsSL https://git.simon-franken.de/simonfranken/submarine/raw/branch/main/install.sh | sh`, or `submarine update`.
Full changelog: https://git.simon-franken.de/simonfranken/submarine/compare/<last>...vX.Y.Z
```
The binaries live in the package registry, not on the release: attach
nothing. Finish with the link to the release and the version.
+11 -4
View File
@@ -20,15 +20,21 @@ 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)
GITEA_TOKEN=… make release # publish a tagged release (outward-facing; run by the developer)
go vet ./...
go test ./...
gofmt -l . # must print nothing
```
## Releasing
Use the `release` skill (`/release`, `.claude/skills/release/SKILL.md`): it
picks the next semver, runs a check, tags and pushes, asks the developer to
run `make release`, then creates the Gitea release. Never run `make release`
or push tags outside of it.
## Rules
- **Keep docs in sync.** A change to behavior, keys, colors, wording or
@@ -38,8 +44,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)`).
+9 -5
View File
@@ -24,8 +24,9 @@ the questions you actually have:
(`·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.** Fetches in the background on startup and follows changes you make
in other shells within about two seconds.
- **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.
@@ -44,7 +45,6 @@ Update later with `sub update`.
```sh
sub # the repository in the current directory
sub ~/code/project # any directory inside a repository
sub --no-fetch # skip the background fetch
sub --mock # built-in sample data, no repository needed
```
@@ -109,7 +109,10 @@ files.
| `space` | Toggle the selected submodule |
| `home` `end` / `g` `G`, `pgup` `pgdn` | Jump, page |
| `/` | Filter |
| `!` | Problems only |
| `c` | Copy path |
| `b` | Switch branch: pick a local or remote branch; its tip is staged and the repository restored to it |
| `x` | Restore: make the worktree exactly what git records (asks if work would be lost) |
| `f` / `r` | Fetch / reload |
| `?` | Full help |
| `q` | Quit |
@@ -118,8 +121,9 @@ The mouse works too: click to select, click `▸` to toggle, wheel to move.
## Status
A prototype. Browsing is complete; the actions `u` (check out the pin),
`b` (bump the pin to the tip) and `!` (problems only) are planned.
A prototype. Browsing is complete, and `x` restores a submodule or the whole
superproject, and `b` switches its branch. More write actions (stage pin) are being specified in
[docs/actions.md](docs/actions.md).
## Documentation
+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) | the write actions restore and switch branch (done) and stage pin (draft) |
| [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 and `b` switches its
branch ([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. "Problems only" (`!`) view; it can reuse the tree's filter.
1. Stage pin, see [actions.md](actions.md). (Restore and switch branch are done.)
2. (done) "Problems only" (`!`) view, reusing the tree's filter.
+365
View File
@@ -0,0 +1,365 @@
# Actions (draft)
**Status:** [Restore](#restore) (key `x`, [D23](decisions.md)) and
[Switch branch](#switch-branch) (key `b`, [D24](decisions.md)) are implemented. [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` |
| [Switch branch](#switch-branch) | Both: the branch tip is recorded, then restored to | `b` |
There is nothing else for now. Committing, pushing and bumping a pin to the
tip are done outside submarine. A bump is "check out the tip in the
submodule, then `s`" (or switch to the branch with `b`).
## 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.
## Switch branch
Moves the selected repository to another branch, in one step: the tip of the
branch becomes the pin, and the repository is restored to it.
### The popup
`b` opens a popup in the middle of the screen: a search line on top, below it
the branches of the selection, local ones first, then remote ones
(`origin/…`), each group with the newest tip first. Each row shows the name
(`*` marks the checked-out branch), the short SHA and the age of the tip.
The list is read from the disk; fetch (`f`) is what brings new remote
branches.
| Key | Effect |
|---|---|
| text | Filters: every word must be in the name, case-insensitive. The selection stays on its branch if it still matches |
| `↑` `↓`, `pgup` `pgdn` | Select |
| `enter` | Switch to the selected branch |
| `esc` | Close |
Mouse: a click on a branch switches to it (like `enter`), the wheel moves the
selection, a click outside the popup closes it; a click on the title, the
search line or an empty row does nothing. Everything else is ignored while the
popup is open. A header selection switches the superproject.
### Effect
For a **submodule**, with the tip `T` the list showed:
1. The parent's index records `T` as the pin (a staged pin; if `T` is the
committed pin, the index equals `HEAD` again).
2. The submodule, and everything below it, is [restored](#restore) to `T`.
3. HEAD is put on the branch (the branch name is the local one):
- a local branch: checked out;
- a remote branch `origin/feat`: the local branch `feat` is created,
tracking it; if `feat` exists at `T` it is checked out; if it exists
**elsewhere** it is not moved and HEAD stays detached at `T`.
For the **superproject** step 1 is left out: the branch is checked out via the
restore, so its submodules follow the pins of the new commit (staged pins of
the old state are lost, and counted).
Staging comes first, so a failed restore can be repeated with `x`. If the
branch moved since the list was read (a fetch), the switch aborts with
`origin/feat moved; open the list again`; the staged SHA is always the one
shown.
### Confirmation, network, errors
As for restore: the loss is read from the disk and the question
(`Switch src/app to origin/feat? Discards … y switch · n cancel`) appears
only if something would be lost. A submodule that is not initialized can't be
switched (`src/app is not initialized; restore it first`). Network: only what
is missing below, like restore. Lines above the footer: `checking …`,
`switching src/app to origin/feat…`; flash `switched src/app to feat` (or
`… to origin/feat (detached: local feat is elsewhere)`); failure
`switch of src/app to origin/feat failed: …`, until the next key. One
restore or switch runs at a time.
### Implementation note
`git.Provider.Switch` (`internal/submodule/git/switch.go`): verify the ref is
still `T`; `update-index --cacheinfo` in the parent; `restoreRepo` (see
restore); then `switch` / `switch -c <local> --track <remote-branch>`.
`SwitchLoss` is `lossOf` with `T` as the target. `Branches` is one
`for-each-ref`, without the `origin/HEAD` symref.
## 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` |
| `b` switch branch | [Switch branch](#switch-branch) | `this data source can't switch branches`; `wait for the restore of … to finish`; `src/app is not initialized; restore it first` |
| `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.
- Switch: provider (`switch_test.go`): list without `origin/HEAD`, local
first; a remote pick stages the tip and creates a tracking branch; an
existing local branch elsewhere is not moved; a moved ref aborts; the
superproject switch ends exact; the loss is the restore's. Picker
(`branches_test.go`): filter, selection, scrolling. UI (`switch_test.go`):
opens, filters, enter switches, asks with a loss, esc closes, the mock
can't.
- 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?
+30 -5
View File
@@ -12,25 +12,30 @@ cmd/submarine/main.go wiring: choose provider (git or --mock), start the
internal/submodule/ domain model; NO UI imports
model.go Repo, Submodule, Status, PinInfo, Commit, enums
status.go Issues(), HiddenIssues(), Walk()
provider.go Provider interface
provider.go Provider interface; optional Fetcher, Watcher, Locator, Restorer, Switcher
internal/submodule/git/ provider using the git CLI (reads, fetch, watch)
exec.go git runner (environment, errors)
parse.go pure parsers for git output (status, numstat, …)
provider.go Tree, Status, pin → ref resolution
fetch.go Fetch (network), Fetchable
watch.go Watch: polls git metadata for changes
restore.go Restore, RestoreLoss (network: only what is missing)
switch.go Branches, SwitchLoss, Switch (stage the tip, restore, attach the branch)
gitdir.go resolves .git dirs and gitdir: files
internal/submodule/mock/ fixture provider with artificial latency
internal/update/ self-update from the package registry; NO UI imports
internal/tui/ Bubble Tea application
app.go root model: layout, message routing, loading
keys.go key bindings (also feed the help view)
restore.go, switch.go restore / switch flows; a switch reuses restoreState
overlay.go draws the picker over the finished view at the position the picker reports
msgs.go tree/status/fetch/change messages
refresh.go reloader: one load per key, coalesced reruns
theme/ colors, glyphs, issue words, change letters, level styles
tree/ tree component: rows, navigation, mouse, view
detail/ detail pane; changes.go: change preview
header/ header line
branches/ branch picker popup (search line + list)
footer/ key bar (navigation left, global right) and flash messages
```
@@ -64,6 +69,21 @@ 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 Switcher interface {
Branches(ctx context.Context, path string) ([]Branch, error) // local + remote, no network
SwitchLoss(ctx context.Context, path string, b Branch) (Loss, error) // as RestoreLoss
Switch(ctx context.Context, path string, b Branch) (string, error) // branch HEAD ends on, "" if detached
}
```
```go
type Locator interface {
Dir(path string) string // absolute directory, "" if unknown
@@ -119,9 +139,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)
@@ -187,6 +207,11 @@ No CI runner: releases are built and published from a developer machine.
- `submarine/<version>/submarine_<os>_<arch>` and `checksums.txt`
- `submarine-latest/latest/VERSION`, replaced **last**, so a failed upload
never advertises a broken version.
- The `release` skill (`.claude/skills/release/SKILL.md`) drives a release:
latest tag → next semver from the commits since → check (`vet`, `test`,
`gofmt`, `make dist`) → tag and push → the developer runs `make release` →
the skill verifies the registry's `VERSION` and creates the Gitea release
with MCP (notes only; the binaries stay in the registry).
- `install.sh` (`curl -fsSL …/raw/branch/main/install.sh | sh`) detects
OS/arch, verifies the sha256, installs `submarine` and a `sub` symlink to
`~/.local/bin` (`SUBMARINE_INSTALL_DIR`, `SUBMARINE_VERSION`,
@@ -197,7 +222,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
+129 -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.
@@ -258,3 +258,131 @@ 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.
## D24 — Switch branch: stage the tip, then restore
**Context.** Switching branches was left to the shell. Users of a repository
with many submodules want to move one to another branch and have the
superproject record it.
**Decision.** `b` opens a popup (search line, local then remote branches).
Enter on a branch of a submodule stages its tip as the pin in the parent's
index and restores the submodule to it (D23, recursion included); then HEAD
is put on the branch. On the superproject it checks the branch out and
restores below, without staging.
- *Key `b`*, not `s`, which stays reserved for stage pin.
- *Remote branch.* A local branch of that name is created, tracking it. If
one exists at another commit it is **not moved**: HEAD stays detached at
the tip and the flash says so (restore never moves branches, D23).
- *Same loss rules as restore.* The loss is read from the disk; the question
appears only if something is lost. Staged first, so a failed restore can be
repeated with `x`. The pin question of restore (`s` / `c`) is skipped: the
staged pin is being replaced.
- *What the picker showed is what is staged.* The branch carries its SHA; a
fetch may move the ref meanwhile, then the switch aborts
(`origin/feat moved; open the list again`).
- *Network.* Extends D23: a tip often pins nested commits that are not on
disk, so switch may clone and fetch what is missing, like restore. The
list itself uses none; `f` refreshes remote branches.
- *Popup.* The first overlay: drawn over the finished view (after
`zone.Scan`, so no zone marker is cut); the keys go to the picker while it
is open, and so does the mouse: the popup remembers where it was drawn and
tests clicks itself (bubblezone can't see it, it is drawn after the scan).
A click on a branch switches, like `enter`; a click outside closes. The backdrop is dimmed: stripped of its styles and drawn in
`Backdrop`, an adaptive 256-color grey (238 dark / 252 light). `Muted`
(ANSI 8) was too bright to set the popup apart, and ANSI faint (SGR 2) is
ignored by some terminals. Like `Bar` and `Selected` it carries no meaning,
so it may leave the 16 ANSI colors.
**Consequences.** `Switcher` is a new optional provider interface; the mock
does not implement it. The switch runs as the restore does (one write
operation at a time, same lines above the footer).
+49 -20
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.
@@ -56,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
@@ -72,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
@@ -90,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
@@ -108,28 +119,44 @@ commit hashes. It doesn't answer the questions users actually have:
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. See [Filter](ux-guide.md#filter).
selection (the superproject if none is left). See [Filter](ux-guide.md#filter).
### Actions (planned, not implemented)
- **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).
| Key | Action |
|---|---|
| `u` | Check out the pinned commit (`git submodule update`) |
| `b` | Bump the pin to the tip of its ref |
| `!` | Show only submodules with issues |
### 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).
- **F57** `b` opens a popup in the middle of the screen with a search line
and the local and remote branches of the selection (a submodule, or the
superproject). `↑` / `↓` select, `enter` switches, `esc` cancels. For a
submodule the branch tip becomes its staged pin and it is restored to it,
with everything below; HEAD ends on the branch. It asks for confirmation
only if something would be lost. See [actions.md](actions.md#switch-branch).
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
@@ -140,7 +167,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` and `Switch`. 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.
+18
View File
@@ -103,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
+113 -26
View File
@@ -75,6 +75,11 @@ 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 and no pin is staged;
@@ -136,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`
@@ -172,6 +183,7 @@ colors in components.
| Border | ANSI `8` grey | pane and help separators |
| Bar | adaptive 254 (light) / 235 (dark) | header background |
| Selected | adaptive 253 (light) / 237 (dark) | selected row background |
| Backdrop | adaptive 252 (light) / 238 (dark) | the screen behind a popup |
- Background shades have no ANSI-16 equivalent, so they use `AdaptiveColor`.
The background is detected once in `main` before Bubble Tea starts.
@@ -195,13 +207,13 @@ 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 |
@@ -209,7 +221,8 @@ colors in components.
| `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 |
| `/` | Open the filter, see [Filter](#filter) |
| `u`, `b`, `!` | Planned actions (see requirements) |
| `!` | 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).
@@ -235,40 +248,114 @@ match vanish. If nothing matches the tree says `no submodule matches`.
`/` 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.
### Switch branch
`b` opens a popup in the middle of the screen (see
[actions.md](actions.md#switch-branch)). The screen around it stays visible
but dimmed: its colors are dropped and it is drawn in `Backdrop`, darker
than `Muted`, so the popup has the focus.
```
╭──────────────────────────────────────────────────────────────────────╮
│ Switch libs/feature │
│ / filter branches │
│ ──────────────────────────────────────────────────────────────────── │
│ * main 92d1583 3d ago │
│ origin/feat/search 58a2bf1 2h ago │
│ ↑↓ select · enter switch · esc cancel │
╰──────────────────────────────────────────────────────────────────────╯
```
- Border `Border`, title in `Primary` bold, search prompt `Primary`, the
selected row on the `Selected` background, SHA and age `Muted`, the remote
prefix `Muted`, the checked-out branch bold with `*`.
- Width at most 72 columns, height as the list (at most 14 rows), so
filtering does not resize it. While reading: spinner and `reading
branches…`; no match: `no branch matches`; error: red, inside the popup.
- Enter closes the popup and continues on the line above the footer like a
[restore](#restore): `checking`, the question `Switch src/app to
origin/feat? Discards … y switch · n cancel`, `switching …`.
### 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 | yes |
| `!` | 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 |
| `b` | Switch branch (popup) | 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.
- In the [branch popup](#switch-branch): click a branch to switch to it,
wheel to move, click outside to close. The popup is hit-tested by its own
position, not with bubblezone, since it is drawn after `zone.Scan`.
## Accessibility and terminals
+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")
}
}
+132
View File
@@ -0,0 +1,132 @@
package git
import (
"context"
"errors"
"fmt"
"slices"
"strconv"
"strings"
"time"
"submarine/internal/submodule"
)
// switchDir returns the directory of path ("" is the superproject), and the
// submodule's info if it is one.
func (p *Provider) switchDir(path string) (dir string, in info, sub bool, err error) {
if path == "" {
return p.root, info{}, false, nil
}
p.mu.RLock()
in, ok := p.mods[path]
p.mu.RUnlock()
if !ok {
return "", info{}, false, fmt.Errorf("unknown submodule %q", path)
}
if !initialized(in.dir) {
return "", info{}, false, errors.New("not initialized; restore it first")
}
return in.dir, in, true, nil
}
// Branches lists the local and remote-tracking branches of path, local ones
// first, each group by the date of its tip, newest first. It reads only what
// is on disk.
func (p *Provider) Branches(ctx context.Context, path string) ([]submodule.Branch, error) {
dir, _, _, err := p.switchDir(path)
if err != nil {
return nil, err
}
out, err := run(ctx, dir, "for-each-ref",
"--format=%(refname)%00%(objectname)%00%(committerdate:unix)%00%(HEAD)%00%(symref)",
"refs/heads", "refs/remotes")
if err != nil {
return nil, err
}
var branches []submodule.Branch
for _, line := range strings.Split(out, "\n") {
f := strings.Split(line, "\x00")
if len(f) != 5 || f[4] != "" { // a symref is origin/HEAD
continue
}
b := submodule.Branch{Ref: f[0], SHA: f[1], Current: f[3] == "*"}
if secs, err := strconv.ParseInt(f[2], 10, 64); err == nil {
b.When = time.Unix(secs, 0)
}
if name, ok := strings.CutPrefix(f[0], "refs/heads/"); ok {
b.Name, b.Local = name, name
} else if name, ok := strings.CutPrefix(f[0], "refs/remotes/"); ok {
_, local, _ := strings.Cut(name, "/")
b.Name, b.Local, b.Remote = name, local, true
} else {
continue
}
branches = append(branches, b)
}
slices.SortStableFunc(branches, func(a, b submodule.Branch) int {
switch {
case a.Remote != b.Remote:
if a.Remote {
return 1
}
return -1
default:
return b.When.Compare(a.When)
}
})
return branches, nil
}
// SwitchLoss reports what switching path to b would discard: the same as a
// restore to b's tip.
func (p *Provider) SwitchLoss(ctx context.Context, path string, b submodule.Branch) (submodule.Loss, error) {
dir, _, _, err := p.switchDir(path)
if err != nil {
return submodule.Loss{}, err
}
return p.lossOf(ctx, dir, b.SHA), nil
}
// Switch makes b's tip the staged pin of a submodule, restores the repository
// to it (see Restore) and puts HEAD on the branch. For a remote branch the
// local branch of that name is created, tracking it. A local branch that
// exists elsewhere is never moved: HEAD stays detached and "" is returned.
func (p *Provider) Switch(ctx context.Context, path string, b submodule.Branch) (string, error) {
dir, in, sub, err := p.switchDir(path)
if err != nil {
return "", err
}
if sha, ok, err := try(ctx, dir, "rev-parse", "--verify", "-q", b.Ref+"^{commit}"); err != nil {
return "", err
} else if !ok || sha != b.SHA {
return "", fmt.Errorf("%s moved; open the list again", b.Name)
}
if sub { // staged first, so a failed restore can be repeated with x
if _, err := p.exec(ctx, in.superDir, nil, "update-index", "--cacheinfo", "160000,"+b.SHA+","+in.relPath); err != nil {
return "", err
}
}
if err := p.restoreRepo(ctx, dir, b.SHA); err != nil {
return "", err
}
g := func(args ...string) (string, error) {
return p.exec(ctx, dir, nil, append(append([]string{}, restoreArgs...), args...)...)
}
if !b.Remote {
_, err := g("switch", "-q", b.Local)
return b.Local, err
}
switch tip, ok, err := try(ctx, dir, "rev-parse", "--verify", "-q", "refs/heads/"+b.Local); {
case err != nil:
return "", err
case !ok:
_, err := g("switch", "-q", "-c", b.Local, "--track", b.Name)
return b.Local, err
case tip == b.SHA:
_, err := g("switch", "-q", b.Local)
return b.Local, err
}
return "", nil
}
+181
View File
@@ -0,0 +1,181 @@
package git
import (
"context"
"os"
"path/filepath"
"strings"
"testing"
"submarine/internal/submodule"
)
func findBranch(t *testing.T, bs []submodule.Branch, name string) submodule.Branch {
t.Helper()
for _, b := range bs {
if b.Name == name {
return b
}
}
t.Fatalf("no branch %q in %v", name, bs)
return submodule.Branch{}
}
func TestBranchesListsLocalAndRemote(t *testing.T) {
p, root := ownFixture(t)
_ = root
bs, err := p.Branches(context.Background(), "libs/feature")
if err != nil {
t.Fatal(err)
}
var names []string
seenRemote := false
for _, b := range bs {
names = append(names, b.Name)
if b.Remote {
seenRemote = true
} else if seenRemote {
t.Errorf("local branch %s after a remote one: %v", b.Name, names)
}
if b.Name == "origin/HEAD" {
t.Error("origin/HEAD must not be listed")
}
}
rb := findBranch(t, bs, "origin/feat/search")
if !rb.Remote || rb.Local != "feat/search" {
t.Errorf("origin/feat/search = %+v", rb)
}
findBranch(t, bs, "origin/main")
}
func TestSwitchSubmoduleStagesTipAndTracksRemoteBranch(t *testing.T) {
p, root := ownFixture(t)
super := filepath.Join(root, "super")
sub := filepath.Join(super, "libs", "feature")
ctx := context.Background()
bs, err := p.Branches(ctx, "libs/feature")
if err != nil {
t.Fatal(err)
}
b := findBranch(t, bs, "origin/feat/search")
got, err := p.Switch(ctx, "libs/feature", b)
if err != nil {
t.Fatal(err)
}
if got != "feat/search" {
t.Errorf("Switch returned %q, want feat/search", got)
}
if head := out(t, sub, "branch", "--show-current"); head != "feat/search" {
t.Errorf("branch = %q", head)
}
if up := out(t, sub, "rev-parse", "--abbrev-ref", "feat/search@{upstream}"); up != "origin/feat/search" {
t.Errorf("upstream = %q", up)
}
if head := out(t, sub, "rev-parse", "HEAD"); head != b.SHA {
t.Errorf("HEAD = %s, want %s", head, b.SHA)
}
if staged := out(t, super, "ls-files", "-s", "--", "libs/feature"); !strings.Contains(staged, b.SHA) {
t.Errorf("index = %q, want the tip %s staged", staged, b.SHA)
}
assertExact(t, sub)
}
func TestSwitchNeverMovesAnExistingLocalBranch(t *testing.T) {
p, root := ownFixture(t)
sub := filepath.Join(root, "super", "libs", "feature")
ctx := context.Background()
git(t, sub, "branch", "feat/search", "origin/main") // same name, elsewhere
before := out(t, sub, "rev-parse", "feat/search")
bs, err := p.Branches(ctx, "libs/feature")
if err != nil {
t.Fatal(err)
}
b := findBranch(t, bs, "origin/feat/search")
got, err := p.Switch(ctx, "libs/feature", b)
if err != nil {
t.Fatal(err)
}
if got != "" {
t.Errorf("Switch returned %q, want detached", got)
}
if head := out(t, sub, "branch", "--show-current"); head != "" {
t.Errorf("HEAD on %q, want detached", head)
}
if after := out(t, sub, "rev-parse", "feat/search"); after != before {
t.Errorf("branch moved: %s -> %s", before, after)
}
if head := out(t, sub, "rev-parse", "HEAD"); head != b.SHA {
t.Errorf("HEAD = %s, want %s", head, b.SHA)
}
}
func TestSwitchAbortsWhenTheBranchMoved(t *testing.T) {
p, root := ownFixture(t)
sub := filepath.Join(root, "super", "libs", "feature")
ctx := context.Background()
bs, err := p.Branches(ctx, "libs/feature")
if err != nil {
t.Fatal(err)
}
b := findBranch(t, bs, "origin/main")
b.SHA = out(t, sub, "rev-parse", "origin/feat/search") // what the list showed is stale
if _, err := p.Switch(ctx, "libs/feature", b); err == nil || !strings.Contains(err.Error(), "moved") {
t.Errorf("err = %v, want a moved error", err)
}
}
func TestSwitchSuperprojectRestoresSubmodulesToItsPins(t *testing.T) {
p, root := ownFixture(t)
super := filepath.Join(root, "super")
ctx := context.Background()
git(t, super, "branch", "older", "HEAD~1")
bs, err := p.Branches(ctx, "")
if err != nil {
t.Fatal(err)
}
b := findBranch(t, bs, "older")
got, err := p.Switch(ctx, "", b)
if err != nil {
t.Fatal(err)
}
if got != "older" || out(t, super, "branch", "--show-current") != "older" {
t.Errorf("Switch = %q, branch = %q", got, out(t, super, "branch", "--show-current"))
}
assertExact(t, super)
}
func TestSwitchLossCountsChangedFiles(t *testing.T) {
p, root := ownFixture(t)
sub := filepath.Join(root, "super", "libs", "feature")
ctx := context.Background()
bs, err := p.Branches(ctx, "libs/feature")
if err != nil {
t.Fatal(err)
}
b := findBranch(t, bs, "origin/feat/search")
loss, err := p.SwitchLoss(ctx, "libs/feature", b)
if err != nil {
t.Fatal(err)
}
if !loss.Empty() {
t.Errorf("clean checkout: loss = %+v", loss)
}
if err := os.WriteFile(filepath.Join(sub, "new.txt"), []byte("x"), 0o644); err != nil {
t.Fatal(err)
}
if loss, err = p.SwitchLoss(ctx, "libs/feature", b); err != nil || loss.Files != 1 {
t.Errorf("loss = %+v, %v; want 1 file", loss, err)
}
}
func TestBranchesOfAMissingSubmoduleSaysSo(t *testing.T) {
p, _ := ownFixture(t)
if _, err := p.Branches(context.Background(), "libs/missing"); err == nil || !strings.Contains(err.Error(), "not initialized") {
t.Errorf("err = %v", err)
}
}
+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
+76 -1
View File
@@ -1,6 +1,9 @@
package submodule
import "context"
import (
"context"
"time"
)
// Provider supplies repository data. Tree is expected to be fast (structure
// only); Status may be slow and is called once per submodule, concurrently.
@@ -33,6 +36,78 @@ 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
}
// Branch is a local or remote-tracking branch of a repository.
type Branch struct {
Ref string // full ref, e.g. "refs/remotes/origin/feat"
Name string // short name, e.g. "origin/feat"
Local string // name of the local branch it stands for, e.g. "feat"
Remote bool // a remote-tracking branch
Current bool // HEAD is on this branch
SHA string // the tip when the list was read
When time.Time // committer date of the tip
}
// Switcher is implemented by providers that can switch a repository to a
// branch: the tip becomes the staged pin of a submodule, and the repository
// (with everything below it) is restored to it. Path "" is the superproject.
// Like Restore, Switch may clone or fetch what is missing below, but must
// never prompt the user. Branches uses no network.
type Switcher interface {
Branches(ctx context.Context, path string) ([]Branch, error)
// SwitchLoss reports what Switch would discard, read from the disk now.
SwitchLoss(ctx context.Context, path string, b Branch) (Loss, error)
// Switch returns the branch HEAD ends up on, "" if it is detached. It
// fails if b.Ref no longer points at b.SHA.
Switch(ctx context.Context, path string, b Branch) (string, 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
+11 -3
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"})
@@ -70,9 +74,13 @@ func (h Hidden) Total() int { return h.Warn + h.Danger }
// 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++
}
+11
View File
@@ -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")
}
}
+94 -23
View File
@@ -15,6 +15,7 @@ import (
zone "github.com/lrstanley/bubblezone"
"submarine/internal/submodule"
"submarine/internal/tui/branches"
"submarine/internal/tui/detail"
"submarine/internal/tui/footer"
"submarine/internal/tui/header"
@@ -29,17 +30,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
@@ -62,11 +57,16 @@ type Model struct {
// exactly then. The input is focused while typing and blurred after enter.
filtering bool
restore *restoreState // the restore or switch in progress, nil if none
picker *branches.Model // the branch picker, nil if closed
pickerPath string // the repository the picker is for
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))
@@ -79,7 +79,6 @@ func New(ctx context.Context, p submodule.Provider, opts Options) Model {
m := Model{
ctx: ctx,
provider: p,
opts: opts,
theme: t,
keys: k,
loads: newReloader(),
@@ -163,10 +162,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.picker != nil && m.picker.Loading()) || (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)
})
@@ -199,6 +198,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)
@@ -208,6 +208,15 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case changeMsg:
return m.changed(msg)
case branchesMsg:
return m.branchesLoaded(msg)
case lossMsg:
return m.lossChecked(msg)
case restoreDoneMsg:
return m.restoreDone(msg)
case fetchDoneMsg:
m.fetch.Done++
if msg.err != nil {
@@ -234,6 +243,9 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, cmd
case tea.MouseMsg:
if m.picker != nil {
return m.mousePicker(msg)
}
m.tree.Update(msg)
return m, nil
@@ -267,7 +279,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
@@ -275,7 +287,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)
@@ -283,9 +300,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)
}
@@ -305,6 +319,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 {
@@ -322,6 +340,21 @@ 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.picker != nil {
return m.pickBranch(msg)
}
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)
}
@@ -377,10 +410,13 @@ func (m Model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
m.filtering = true
m.layout()
return m, m.filter.Focus()
case key.Matches(msg, k.Update, k.Bump, k.Issues):
cmd := m.footer.Flash("“" + msg.String() + "” is not implemented in the prototype")
case key.Matches(msg, k.Restore):
return m.startRestore()
case key.Matches(msg, k.Switch):
return m.startSwitch()
case key.Matches(msg, k.Issues):
m.tree.SetProblemsOnly(!m.tree.ProblemsOnly())
m.layout()
return m, cmd
}
return m, nil
}
@@ -429,6 +465,9 @@ func (m Model) filterView() string {
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()
@@ -436,6 +475,22 @@ func (m Model) filterView() string {
return lipgloss.NewStyle().Width(m.width).Padding(0, 1).Render(in + strings.Repeat(" ", gap) + hint)
}
// extraLine reports whether a line above the footer is shown: 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) {
@@ -444,6 +499,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
@@ -458,7 +516,7 @@ func (m Model) copyPath() (tea.Model, tea.Cmd) {
func (m *Model) layout() {
m.footer.SetWidth(m.width)
bodyHeight := m.height - headerHeight - lipgloss.Height(m.footer.View())
if m.filtering {
if m.extraLine() {
bodyHeight--
}
bodyHeight = max(bodyHeight, 0)
@@ -488,13 +546,26 @@ 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)
}
parts := []string{header.View(m.theme, m.repo, m.fetch, m.spinner.View(), m.width), body}
if m.filtering {
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())...))
view := zone.Scan(lipgloss.JoinVertical(lipgloss.Left, append(parts, m.footer.View())...))
if m.picker != nil { // after zone.Scan, so no zone marker is cut
pop := m.picker.View(m.width, m.height)
x, y := m.picker.Origin(m.width, m.height)
view = overlay(view, pop, x, y, theme.Fg(m.theme.Backdrop))
}
return view
}
+252
View File
@@ -0,0 +1,252 @@
// Package branches is the branch picker: a search line above a list of
// branches. It holds the state and renders itself; the app decides what the
// keys do.
package branches
import (
"strings"
"github.com/charmbracelet/bubbles/textinput"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/charmbracelet/x/ansi"
"submarine/internal/submodule"
"submarine/internal/tui/theme"
)
const (
maxWidth = 72
minWidth = 36
maxRows = 14
frameRows = 6 // border, title, search, rule, hint, border
)
// Model is the picker. The zero value is not usable, use New.
type Model struct {
theme theme.Theme
title string
input textinput.Model
all []submodule.Branch
shown []int // indexes into all that match the search
cursor int // index into shown
offset int // first visible index into shown
rows int // visible list rows
loading bool
err error
spinner string
w, h int // size of the popup as last rendered, for hit tests
}
// New returns a picker in the loading state.
func New(t theme.Theme, title string) *Model {
in := textinput.New()
in.Prompt = "/ "
in.PromptStyle = theme.Fg(t.Primary)
in.Cursor.Style = theme.Fg(t.Primary)
in.Placeholder = "filter branches"
in.PlaceholderStyle = theme.Fg(t.Muted)
in.Focus()
return &Model{theme: t, title: title, input: in, loading: true, rows: maxRows}
}
// SetBranches fills the list and ends the loading state.
func (m *Model) SetBranches(bs []submodule.Branch) {
m.all, m.loading, m.err = bs, false, nil
m.refilter()
}
// SetErr shows err instead of the list.
func (m *Model) SetErr(err error) { m.loading, m.err = false, err }
// SetSpinner sets the spinner frame shown while loading.
func (m *Model) SetSpinner(s string) { m.spinner = s }
// Loading reports whether the list is still being read.
func (m *Model) Loading() bool { return m.loading }
// Selected returns the branch under the cursor, nil if none matches.
func (m *Model) Selected() *submodule.Branch {
if m.cursor < 0 || m.cursor >= len(m.shown) {
return nil
}
b := m.all[m.shown[m.cursor]]
return &b
}
// Title is what the picker switches, e.g. "src/app".
func (m *Model) Title() string { return m.title }
// Search is the text in the search line.
func (m *Model) Search() string { return m.input.Value() }
func (m *Model) Up() { m.move(-1) }
func (m *Model) Down() { m.move(1) }
func (m *Model) PageUp() { m.move(-max(m.rows-1, 1)) }
func (m *Model) PageDown() { m.move(max(m.rows-1, 1)) }
func (m *Model) move(d int) {
m.cursor = max(min(m.cursor+d, len(m.shown)-1), 0)
m.scroll()
}
func (m *Model) scroll() {
if m.cursor < m.offset {
m.offset = m.cursor
}
if m.cursor >= m.offset+m.rows {
m.offset = m.cursor - m.rows + 1
}
m.offset = max(min(m.offset, len(m.shown)-m.rows), 0)
}
// Update edits the search line. The cursor stays on the same branch if it
// still matches.
func (m *Model) Update(msg tea.Msg) tea.Cmd {
var cmd tea.Cmd
before := m.input.Value()
m.input, cmd = m.input.Update(msg)
if m.input.Value() != before {
m.refilter()
}
return cmd
}
// refilter recomputes shown: every word of the search must be in the name.
func (m *Model) refilter() {
var keep string
if b := m.Selected(); b != nil {
keep = b.Ref
}
words := strings.Fields(strings.ToLower(m.input.Value()))
m.shown = m.shown[:0]
m.cursor = 0
for i, b := range m.all {
name := strings.ToLower(b.Name)
ok := true
for _, w := range words {
ok = ok && strings.Contains(name, w)
}
if !ok {
continue
}
if b.Ref == keep {
m.cursor = len(m.shown)
}
m.shown = append(m.shown, i)
}
m.offset = 0
m.scroll()
}
// View renders the popup for a terminal of the given size.
func (m *Model) View(termWidth, termHeight int) string {
width := max(min(maxWidth, termWidth-4), minWidth)
width = min(width, termWidth)
inner := max(width-4, 1) // border and padding
// the list is as tall as the branches, so filtering doesn't resize it
m.rows = max(min(max(len(m.all), 3), maxRows, termHeight-frameRows-2), 1)
m.scroll()
m.input.Width = max(inner-3, 1)
muted := theme.Fg(m.theme.Muted)
lines := []string{
theme.Fg(m.theme.Primary).Bold(true).Render(ansi.Truncate("Switch "+m.title, inner, "…")),
m.input.View(),
muted.Render(strings.Repeat(m.theme.Glyphs.Rule, inner)),
}
var body []string
switch {
case m.loading:
body = []string{theme.Fg(m.theme.Primary).Render(m.spinner) + " " + muted.Render("reading branches…")}
case m.err != nil:
body = []string{theme.Fg(m.theme.Danger).Render(ansi.Truncate(m.err.Error(), inner, "…"))}
case len(m.shown) == 0 && len(m.all) == 0:
body = []string{muted.Render("no branches")}
case len(m.shown) == 0:
body = []string{muted.Render("no branch matches")}
default:
end := min(m.offset+m.rows, len(m.shown))
for i := m.offset; i < end; i++ {
body = append(body, m.row(m.all[m.shown[i]], i == m.cursor, inner))
}
}
for len(body) < m.rows {
body = append(body, "")
}
lines = append(lines, body[:m.rows]...)
lines = append(lines, muted.Render(ansi.Truncate("↑↓ select · enter switch · esc cancel", inner, "…")))
out := lipgloss.NewStyle().
Border(lipgloss.RoundedBorder()).
BorderForeground(m.theme.Border).
Padding(0, 1).
Render(strings.Join(lines, "\n"))
m.w, m.h = lipgloss.Width(out), lipgloss.Height(out)
return out
}
// Origin is the top-left cell of the popup, centered in a terminal of the
// given size. Valid after View.
func (m *Model) Origin(termWidth, termHeight int) (x, y int) {
return max((termWidth-m.w)/2, 0), max((termHeight-m.h)/2, 0)
}
// listTop is the line of the popup (border, title, search, rule) where the
// list starts.
const listTop = 4
// Click handles a left click at cell (x, y) of a terminal of the given size.
// inside reports whether the click was within the popup. If it hit a branch,
// that branch is selected and hit is true.
func (m *Model) Click(x, y, termWidth, termHeight int) (inside, hit bool) {
ox, oy := m.Origin(termWidth, termHeight)
x, y = x-ox, y-oy
if x < 0 || y < 0 || x >= m.w || y >= m.h {
return false, false
}
row := y - listTop
if m.loading || m.err != nil || x == 0 || x == m.w-1 || row < 0 || row >= m.rows || m.offset+row >= len(m.shown) {
return true, false
}
m.cursor = m.offset + row
return true, true
}
// row renders one branch: the name, right-aligned the short SHA and age.
func (m *Model) row(b submodule.Branch, selected bool, width int) string {
base := lipgloss.NewStyle()
if selected {
base = base.Background(m.theme.Selected)
}
marker := " "
if b.Current {
marker = "*"
}
name, prefix := b.Name, ""
if b.Remote {
if remote, rest, ok := strings.Cut(b.Name, "/"); ok {
prefix, name = remote+"/", rest
}
}
right := base.Foreground(m.theme.Muted).Render(short(b.SHA) + " " + theme.Ago(b.When))
room := max(width-lipgloss.Width(right)-3, 1)
nameStyle := base.Foreground(m.theme.Text)
if b.Current {
nameStyle = nameStyle.Bold(true)
}
left := base.Foreground(m.theme.Primary).Render(marker) + base.Render(" ") +
base.Foreground(m.theme.Muted).Render(prefix) + nameStyle.Render(name)
left = ansi.Truncate(left, room, "…")
gap := max(width-lipgloss.Width(left)-lipgloss.Width(right), 1)
return left + base.Render(strings.Repeat(" ", gap)) + right
}
func short(sha string) string {
if len(sha) > 7 {
return sha[:7]
}
return sha
}
+170
View File
@@ -0,0 +1,170 @@
package branches
import (
"errors"
"strings"
"testing"
tea "github.com/charmbracelet/bubbletea"
"submarine/internal/submodule"
"submarine/internal/tui/theme"
)
func list() []submodule.Branch {
return []submodule.Branch{
{Ref: "refs/heads/main", Name: "main", Local: "main", Current: true, SHA: "aaaaaaaaaa"},
{Ref: "refs/heads/dev", Name: "dev", Local: "dev", SHA: "bbbbbbbbbb"},
{Ref: "refs/remotes/origin/main", Name: "origin/main", Local: "main", Remote: true, SHA: "aaaaaaaaaa"},
{Ref: "refs/remotes/origin/feat/search", Name: "origin/feat/search", Local: "feat/search", Remote: true, SHA: "cccccccccc"},
}
}
func typed(m *Model, s string) {
for _, r := range s {
m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{r}})
}
}
func name(m *Model) string {
if b := m.Selected(); b != nil {
return b.Name
}
return ""
}
func TestSelectionMovesAndClamps(t *testing.T) {
m := New(theme.Default(), "src/app")
m.SetBranches(list())
if name(m) != "main" {
t.Fatalf("first = %q", name(m))
}
m.Up()
if name(m) != "main" {
t.Errorf("up at the top = %q", name(m))
}
m.Down()
m.Down()
if name(m) != "origin/main" {
t.Errorf("down twice = %q", name(m))
}
m.PageDown()
if name(m) != "origin/feat/search" {
t.Errorf("page down = %q, want the last", name(m))
}
m.Down()
if name(m) != "origin/feat/search" {
t.Errorf("down at the end = %q", name(m))
}
}
func TestSearchFiltersAndAllWordsMustMatch(t *testing.T) {
m := New(theme.Default(), "src/app")
m.SetBranches(list())
typed(m, "ORIGIN")
if len(m.shown) != 2 {
t.Errorf("origin matches %d, want 2", len(m.shown))
}
typed(m, " sea")
if len(m.shown) != 1 || name(m) != "origin/feat/search" {
t.Errorf("shown = %d, selected %q", len(m.shown), name(m))
}
typed(m, "zzz")
if m.Selected() != nil {
t.Errorf("no match must select nothing, got %q", name(m))
}
if !strings.Contains(m.View(100, 30), "no branch matches") {
t.Error("view does not say that nothing matches")
}
}
func TestSelectionStaysOnTheSameBranchWhenItStillMatches(t *testing.T) {
m := New(theme.Default(), "src/app")
m.SetBranches(list())
m.Down()
m.Down() // origin/main
typed(m, "main")
if name(m) != "origin/main" {
t.Errorf("selected %q, want origin/main kept", name(m))
}
}
func TestViewStates(t *testing.T) {
m := New(theme.Default(), "src/app")
if v := m.View(100, 30); !strings.Contains(v, "reading branches") || !strings.Contains(v, "Switch src/app") {
t.Errorf("loading view:\n%s", v)
}
m.SetErr(errors.New("not initialized; restore it first"))
if v := m.View(100, 30); !strings.Contains(v, "not initialized") {
t.Errorf("error view:\n%s", v)
}
m.SetBranches(nil)
if v := m.View(100, 30); !strings.Contains(v, "no branches") {
t.Errorf("empty view:\n%s", v)
}
m.SetBranches(list())
v := m.View(100, 30)
for _, want := range []string{"main", "origin/", "feat/search", "aaaaaaa", "enter switch"} {
if !strings.Contains(v, want) {
t.Errorf("view lacks %q:\n%s", want, v)
}
}
}
func TestListScrollsInASmallTerminal(t *testing.T) {
var many []submodule.Branch
for i := 0; i < 30; i++ {
n := string(rune('a'+i%26)) + string(rune('a'+i/26))
many = append(many, submodule.Branch{Ref: "refs/heads/" + n, Name: n, Local: n, SHA: "abcdef0123"})
}
m := New(theme.Default(), "x")
m.SetBranches(many)
m.View(80, 14) // fewer rows than branches
for i := 0; i < 20; i++ {
m.Down()
}
if m.cursor < m.offset || m.cursor >= m.offset+m.rows {
t.Errorf("cursor %d outside the window [%d,%d)", m.cursor, m.offset, m.offset+m.rows)
}
}
func TestClickSelectsTheBranchUnderTheMouse(t *testing.T) {
m := New(theme.Default(), "src/app")
m.SetBranches(list())
m.View(100, 30)
x, y := m.Origin(100, 30)
if inside, hit := m.Click(x+3, y+listTop+1, 100, 30); !inside || !hit || name(m) != "dev" {
t.Errorf("click on the 2nd row: inside=%v hit=%v selected %q", inside, hit, name(m))
}
if inside, hit := m.Click(x+3, y+listTop+3, 100, 30); !inside || !hit || name(m) != "origin/feat/search" {
t.Errorf("click on the 4th row: inside=%v hit=%v selected %q", inside, hit, name(m))
}
for _, c := range []struct {
name string
x, y int
}{
{"title", x + 3, y + 1},
{"search line", x + 3, y + 2},
{"hint line", x + 3, y + m.h - 2},
{"left border", x, y + listTop},
} {
before := name(m)
if inside, hit := m.Click(c.x, c.y, 100, 30); !inside || hit || name(m) != before {
t.Errorf("click on the %s: inside=%v hit=%v selected %q", c.name, inside, hit, name(m))
}
}
if inside, hit := m.Click(0, 0, 100, 30); inside || hit {
t.Errorf("click outside: inside=%v hit=%v", inside, hit)
}
}
func TestClickBelowTheLastBranchHitsNothing(t *testing.T) {
m := New(theme.Default(), "x")
m.SetBranches(list()[:1]) // 1 branch, the list is padded to 3 rows
m.View(100, 30)
x, y := m.Origin(100, 30)
if inside, hit := m.Click(x+3, y+listTop+2, 100, 30); !inside || hit {
t.Errorf("inside=%v hit=%v on an empty row", inside, hit)
}
}
+67 -31
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 {
@@ -104,7 +111,11 @@ func (m Model) lines(s *submodule.Submodule) []string {
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,29 +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 h.Staged > 0 {
parts = append(parts, theme.Fg(t.Info).Render(fmt.Sprintf("%d %s", h.Staged, theme.Staged)))
}
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"
@@ -191,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
+10 -9
View File
@@ -3,11 +3,12 @@ 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, Switch key.Binding
Help, Quit key.Binding
}
func defaultKeys() keyMap {
@@ -23,12 +24,12 @@ 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")),
Switch: key.NewBinding(key.WithKeys("b"), key.WithHelp("b", "switch branch")),
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 +46,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.Switch, 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 +60,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.Switch, k.Restore},
{k.Fetch, k.Reload, k.Filter, k.Issues},
{k.Help, k.Quit},
}
+33
View File
@@ -0,0 +1,33 @@
package tui
import (
"strings"
"github.com/charmbracelet/lipgloss"
"github.com/charmbracelet/x/ansi"
)
// overlay draws fg over bg with its top-left cell at (x, y). bg must be the
// final string (after zone.Scan): its lines are cut at the popup's edges.
// The backdrop loses its colors and is drawn in dim, so the popup stands out.
func overlay(bg, fg string, x, y int, dim lipgloss.Style) string {
lines := strings.Split(bg, "\n")
for i, l := range lines {
lines[i] = dim.Render(ansi.Strip(l))
}
pop := strings.Split(fg, "\n")
pw := lipgloss.Width(fg)
left := x
for i, p := range pop {
row := y + i
if row >= len(lines) {
break
}
line := lines[row]
if lw := ansi.StringWidth(line); lw < left {
line += strings.Repeat(" ", left-lw)
}
lines[row] = ansi.Truncate(line, left, "") + "\x1b[0m" + p + "\x1b[0m" + ansi.TruncateLeft(line, left+pw, "")
}
return strings.Join(lines, "\n")
}
+36
View File
@@ -0,0 +1,36 @@
package tui
import (
"strings"
"testing"
"github.com/charmbracelet/lipgloss"
"github.com/charmbracelet/x/ansi"
)
func TestOverlayCentersThePopup(t *testing.T) {
bg := strings.Repeat(strings.Repeat(".", 10)+"\n", 4) + strings.Repeat(".", 10)
got := strings.Split(ansi.Strip(overlay(bg, "ab\ncd", 4, 1, lipgloss.NewStyle())), "\n")
want := []string{"..........", "....ab....", "....cd....", "..........", ".........."}
if len(got) != len(want) {
t.Fatalf("got %d lines", len(got))
}
for i := range want {
if got[i] != want[i] {
t.Errorf("line %d = %q, want %q", i, got[i], want[i])
}
}
}
func TestOverlayDimsTheBackdrop(t *testing.T) {
bg := "\x1b[31mredredred\x1b[0m"
dim := lipgloss.NewStyle().Foreground(lipgloss.Color("8"))
got := overlay(bg, "X", 4, 0, dim)
if ansi.StringWidth(got) != 9 || ansi.Strip(got) != "redrXdred" {
t.Errorf("got %q", ansi.Strip(got))
}
if strings.Contains(got, "\x1b[31m") {
t.Errorf("the backdrop keeps its colors: %q", got)
}
}
+302
View File
@@ -0,0 +1,302 @@
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
branch *submodule.Branch // set for a branch switch, which reuses this flow
}
// 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
branch string // the branch a switch ended on, "" if detached
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()
ctx, path, to, b := m.ctx, m.restore.path, m.restore.to, m.restore.branch
check := func() tea.Msg {
var loss submodule.Loss
var err error
if b != nil {
loss, err = m.provider.(submodule.Switcher).SwitchLoss(ctx, path, *b)
} else {
loss, err = m.provider.(submodule.Restorer).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()
ctx, path, to, b := m.ctx, m.restore.path, m.restore.to, m.restore.branch
run := func() tea.Msg {
if b != nil {
branch, err := m.provider.(submodule.Switcher).Switch(ctx, path, *b)
return restoreDoneMsg{path, branch, err}
}
return restoreDoneMsg{path, "", m.provider.(submodule.Restorer).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) {
r := m.restore
m.restore = nil
var cmds []tea.Cmd
switch {
case r != nil && r.branch != nil && msg.err != nil:
m.notice = "switch of " + r.name + " to " + r.branch.Name + " failed: " + msg.err.Error()
case r != nil && r.branch != nil:
to := r.branch.Name
if msg.branch != "" {
to = msg.branch
} else if r.branch.Remote {
to += " (detached: local " + r.branch.Local + " is elsewhere)"
}
cmds = append(cmds, m.footer.Flash("switched "+r.name+" to "+to))
default:
name := "the superproject"
if r != nil {
name = r.name + r.toText()
}
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 origin/feat" for a switch, " 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.branch != nil {
return " to " + r.branch.Name
}
if r.to == submodule.RestoreCommitted {
return " to the committed pin"
}
return ""
}
// verb and verbIng word the operation: a switch is shown like a restore, but
// names its branch.
func (r *restoreState) verb() string {
if r.branch != nil {
return "Switch"
}
return "Restore"
}
func (r *restoreState) verbIng() string {
if r.branch != nil {
return "switching"
}
return "restoring"
}
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(r.verbIng()+" "+r.name+r.toText()+"…")
case restoreAsking:
left = theme.Fg(m.theme.Warn).Render(r.verb()+" "+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(" "+strings.ToLower(r.verb())+" · ") +
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, branchesMsg:
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)
}
}
+125
View File
@@ -0,0 +1,125 @@
package tui
import (
tea "github.com/charmbracelet/bubbletea"
"submarine/internal/submodule"
"submarine/internal/tui/branches"
)
// branchesMsg is the branch list for the picker of path.
type branchesMsg struct {
path string
branches []submodule.Branch
err error
}
// startSwitch opens the branch picker for the selection and reads its
// branches.
func (m Model) startSwitch() (tea.Model, tea.Cmd) {
s := m.tree.Selected()
sw, ok := m.provider.(submodule.Switcher)
var flash string
switch {
case s == nil:
return m, nil
case !ok:
flash = "this data source can't switch branches"
case m.restore != nil:
flash = "wait for the restore of " + m.restore.name + " to finish"
case !s.IsRoot() && s.Status != nil && s.Status.Presence != submodule.PresenceOK:
flash = s.Path + " is not initialized; restore it first"
}
if flash != "" {
cmd := m.footer.Flash(flash)
m.layout()
return m, cmd
}
m.picker, m.pickerPath = branches.New(m.theme, restoreName(s)), s.Path
m.picker.SetSpinner(m.spinner.View())
ctx, path := m.ctx, s.Path
load := func() tea.Msg {
bs, err := sw.Branches(ctx, path)
return branchesMsg{path, bs, err}
}
return m, tea.Batch(load, m.tick())
}
// branchesLoaded fills the picker, unless it was closed or is for another
// repository meanwhile.
func (m Model) branchesLoaded(msg branchesMsg) (tea.Model, tea.Cmd) {
if m.picker == nil || m.pickerPath != msg.path {
return m, nil
}
if msg.err != nil {
m.picker.SetErr(msg.err)
} else {
m.picker.SetBranches(msg.branches)
}
return m, nil
}
// pickBranch handles a key while the picker is open: arrows move, enter
// switches, esc closes, everything else edits the search.
func (m Model) pickBranch(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
switch msg.Type {
case tea.KeyCtrlC:
return m, tea.Quit
case tea.KeyEsc:
m.picker = nil
case tea.KeyUp:
m.picker.Up()
case tea.KeyDown:
m.picker.Down()
case tea.KeyPgUp:
m.picker.PageUp()
case tea.KeyPgDown:
m.picker.PageDown()
case tea.KeyEnter:
return m.confirmPick()
default:
return m, m.picker.Update(msg)
}
return m, nil
}
// confirmPick closes the picker and switches to the selected branch, with
// the same check and question as a restore.
func (m Model) confirmPick() (tea.Model, tea.Cmd) {
b := m.picker.Selected()
if b == nil {
return m, nil
}
s := m.byPath[m.pickerPath]
m.picker = nil
if s == nil || m.restore != nil {
return m, nil
}
m.restore = &restoreState{path: s.Path, name: restoreName(s), branch: b}
return m.checkRestore()
}
// mousePicker handles the mouse while the picker is open: the wheel moves
// the selection, a click on a branch switches to it, a click outside the
// popup closes it.
func (m Model) mousePicker(msg tea.MouseMsg) (tea.Model, tea.Cmd) {
switch msg.Button {
case tea.MouseButtonWheelUp:
m.picker.Up()
case tea.MouseButtonWheelDown:
m.picker.Down()
case tea.MouseButtonLeft:
if msg.Action != tea.MouseActionPress {
return m, nil
}
inside, hit := m.picker.Click(msg.X, msg.Y, m.width, m.height)
switch {
case hit:
return m.confirmPick()
case !inside:
m.picker = nil
}
}
return m, nil
}
+224
View File
@@ -0,0 +1,224 @@
package tui
import (
"context"
"strings"
"testing"
tea "github.com/charmbracelet/bubbletea"
"submarine/internal/submodule"
"submarine/internal/submodule/mock"
)
// fakeSwitcher is the mock data with a branch switch that records its calls.
type fakeSwitcher struct {
submodule.Provider
loss submodule.Loss
branches []submodule.Branch
switched []string // path + " " + ref
ends string // the branch Switch reports
}
func (f *fakeSwitcher) Branches(context.Context, string) ([]submodule.Branch, error) {
return f.branches, nil
}
func (f *fakeSwitcher) SwitchLoss(context.Context, string, submodule.Branch) (submodule.Loss, error) {
return f.loss, nil
}
func (f *fakeSwitcher) Switch(_ context.Context, path string, b submodule.Branch) (string, error) {
f.switched = append(f.switched, path+" "+b.Ref)
return f.ends, nil
}
func newSwitchModel(t *testing.T, loss submodule.Loss) (tea.Model, *fakeSwitcher) {
t.Helper()
f := &fakeSwitcher{
Provider: mock.New(),
loss: loss,
ends: "feat",
branches: []submodule.Branch{
{Ref: "refs/heads/main", Name: "main", Local: "main", Current: true, SHA: "aaaaaaa"},
{Ref: "refs/remotes/origin/feat", Name: "origin/feat", Local: "feat", Remote: true, SHA: "bbbbbbb"},
},
}
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
}
// openPicker presses b and delivers the branch list.
func openPicker(t *testing.T, m tea.Model) tea.Model {
t.Helper()
m, cmd := press(m, "b")
m = feed(m, cmd)
if m.(Model).picker == nil {
t.Fatal("the picker did not open")
}
return m
}
func pressKey(m tea.Model, t tea.KeyType) (tea.Model, tea.Cmd) {
return m.Update(tea.KeyMsg{Type: t})
}
// settle delivers the loss check and, if it ran at once, the switch.
func settle(m tea.Model, cmd tea.Cmd) tea.Model {
var next tea.Cmd
for _, msg := range run(cmd) {
if l, ok := msg.(lossMsg); ok {
m, next = m.Update(l)
}
}
return feed(m, next)
}
func TestSwitchOpensThePickerWithTheBranches(t *testing.T) {
m, _ := newSwitchModel(t, submodule.Loss{})
m = openPicker(t, m)
v := m.View()
for _, want := range []string{"Switch the superproject", "main", "origin/", "feat"} {
if !strings.Contains(v, want) {
t.Errorf("view lacks %q", want)
}
}
}
func TestSwitchTypingFiltersAndEnterSwitchesWithoutLoss(t *testing.T) {
m, f := newSwitchModel(t, submodule.Loss{})
m = openPicker(t, m)
m, _ = press(m, "fe") // j and k would be text, not navigation
m, cmd := pressKey(m, tea.KeyEnter)
if m.(Model).picker != nil {
t.Error("enter must close the picker")
}
m = settle(m, cmd)
if len(f.switched) != 1 || f.switched[0] != " refs/remotes/origin/feat" {
t.Fatalf("switched %q", f.switched)
}
if m.(Model).restore != nil {
t.Error("switch still in progress")
}
}
func TestSwitchArrowsSelect(t *testing.T) {
m, f := newSwitchModel(t, submodule.Loss{})
m = openPicker(t, m)
m, _ = pressKey(m, tea.KeyDown)
m, cmd := pressKey(m, tea.KeyEnter)
settle(m, cmd)
if len(f.switched) != 1 || f.switched[0] != " refs/remotes/origin/feat" {
t.Fatalf("switched %q, want the second branch", f.switched)
}
}
func TestSwitchAsksWhenSomethingIsLost(t *testing.T) {
m, f := newSwitchModel(t, submodule.Loss{Files: 2, Modules: 1})
m = openPicker(t, m)
m, cmd := pressKey(m, tea.KeyEnter)
var next tea.Cmd
for _, msg := range run(cmd) {
if l, ok := msg.(lossMsg); ok {
m, next = m.Update(l)
}
}
r := m.(Model).restore
if next != nil || r == nil || r.phase != restoreAsking {
t.Fatalf("want a question, got %+v", r)
}
if v := m.View(); !strings.Contains(v, "Switch the superproject to main?") || !strings.Contains(v, "y switch") {
t.Errorf("question not shown:\n%s", v)
}
if c, _ := press(m, "n"); c.(Model).restore != nil || len(f.switched) != 0 {
t.Fatal("n must cancel")
}
m, cmd = press(m, "y")
feed(m, cmd)
if len(f.switched) != 1 {
t.Fatalf("switched %q", f.switched)
}
}
func TestSwitchEscClosesThePicker(t *testing.T) {
m, f := newSwitchModel(t, submodule.Loss{})
m = openPicker(t, m)
m, _ = pressKey(m, tea.KeyEsc)
if m.(Model).picker != nil || len(f.switched) != 0 {
t.Fatal("esc must close without switching")
}
}
func TestSwitchWithoutSwitcherSaysSo(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, "b")
if m.(Model).picker != nil {
t.Fatal("the mock can't switch")
}
if !strings.Contains(m.View(), "can't switch branches") {
t.Error("no flash")
}
}
func TestSwitchReportsADetachedHead(t *testing.T) {
m, f := newSwitchModel(t, submodule.Loss{})
f.ends = ""
m = openPicker(t, m)
m, _ = pressKey(m, tea.KeyDown)
m, cmd := pressKey(m, tea.KeyEnter)
m = settle(m, cmd)
if v := m.View(); !strings.Contains(v, "detached") {
t.Errorf("flash lacks the detached note:\n%s", v)
}
}
// clickRow clicks the i-th visible branch of the open picker.
func clickRow(m tea.Model, i int) (tea.Model, tea.Cmd) {
mm := m.(Model)
x, y := mm.picker.Origin(mm.width, mm.height)
return m.Update(tea.MouseMsg{X: x + 3, Y: y + 4 + i, Button: tea.MouseButtonLeft, Action: tea.MouseActionPress})
}
func TestSwitchClickOnABranchSwitches(t *testing.T) {
m, f := newSwitchModel(t, submodule.Loss{})
m = openPicker(t, m)
m.View() // the popup learns where it is drawn
m, cmd := clickRow(m, 1)
if m.(Model).picker != nil {
t.Error("the click must close the picker")
}
settle(m, cmd)
if len(f.switched) != 1 || f.switched[0] != " refs/remotes/origin/feat" {
t.Fatalf("switched %q, want the clicked branch", f.switched)
}
}
func TestSwitchClickOutsideClosesAndWheelMoves(t *testing.T) {
m, f := newSwitchModel(t, submodule.Loss{})
m = openPicker(t, m)
m.View()
m, _ = m.Update(tea.MouseMsg{Button: tea.MouseButtonWheelDown, Action: tea.MouseActionPress})
if b := m.(Model).picker.Selected(); b == nil || b.Name != "origin/feat" {
t.Errorf("wheel down selected %v", b)
}
// a click that releases or sits inside the popup's frame does nothing
mm := m.(Model)
x, y := mm.picker.Origin(mm.width, mm.height)
m, _ = m.Update(tea.MouseMsg{X: x + 3, Y: y + 1, Button: tea.MouseButtonLeft, Action: tea.MouseActionPress})
if m.(Model).picker == nil {
t.Fatal("a click on the title must keep the popup open")
}
m, _ = m.Update(tea.MouseMsg{X: 0, Y: 0, Button: tea.MouseButtonLeft, Action: tea.MouseActionPress})
if m.(Model).picker != nil || len(f.switched) != 0 {
t.Fatal("a click outside must close without switching")
}
}
+25
View File
@@ -32,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 {
+5 -2
View File
@@ -8,8 +8,9 @@ import (
)
// Theme uses the 16 ANSI colors for everything that carries meaning, so
// the TUI follows the user's terminal color scheme. Only background shades,
// which have no ANSI equivalent, use adaptive 256-color values.
// the TUI follows the user's terminal color scheme. Only background shades
// and the dimmed backdrop, which have no ANSI equivalent, use adaptive
// 256-color values.
type Theme struct {
Primary lipgloss.TerminalColor // accent: branch names, commit hashes
Info lipgloss.TerminalColor
@@ -21,6 +22,7 @@ type Theme struct {
Border lipgloss.TerminalColor // pane separators, help separators
Bar lipgloss.TerminalColor // background of the header bar
Selected lipgloss.TerminalColor // background of the selected row
Backdrop lipgloss.TerminalColor // the screen behind a popup, dimmed
Glyphs Glyphs
}
@@ -40,6 +42,7 @@ func Default() Theme {
Border: lipgloss.Color("8"),
Bar: lipgloss.AdaptiveColor{Light: "254", Dark: "235"},
Selected: lipgloss.AdaptiveColor{Light: "253", Dark: "237"},
Backdrop: lipgloss.AdaptiveColor{Light: "252", Dark: "238"},
Glyphs: g,
}
}
+55 -8
View File
@@ -8,7 +8,7 @@ import (
// SetFilter shows only the submodules whose path contains term (ignoring
// case), plus their ancestors, which are shown expanded. An empty term
// removes the filter.
// 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
@@ -21,15 +21,46 @@ func (m *Model) SetFilter(term string) {
}
old := m.cursor
m.filter = term
if 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()
}
} else {
m.reselect(old)
}
m.clampOffset()
}
@@ -37,6 +68,9 @@ func (m *Model) SetFilter(term string) {
// 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
@@ -48,6 +82,10 @@ func parentOf(s *submodule.Submodule) *submodule.Submodule {
// 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() {
@@ -63,6 +101,9 @@ func (m *Model) reselect(old *submodule.Submodule) {
return
}
m.cursor = first
if first == nil {
m.cursor = m.root // nothing matches: the superproject remains
}
if old == nil || first == nil {
return
}
@@ -99,10 +140,10 @@ func (m *Model) appendAll(out *[]row, mods []*submodule.Submodule, depth int) {
}
}
// isExpanded reports whether the children of s are shown. While filtering,
// 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.filter == "" {
if !m.narrowed() {
return m.expanded[s.Path]
}
for _, c := range s.Children {
@@ -115,7 +156,7 @@ func (m *Model) isExpanded(s *submodule.Submodule) bool {
// filtered returns the submodules of mods that pass the filter.
func (m *Model) filtered(mods []*submodule.Submodule) []*submodule.Submodule {
if m.filter == "" {
if !m.narrowed() {
return mods
}
var out []*submodule.Submodule
@@ -129,7 +170,7 @@ func (m *Model) filtered(mods []*submodule.Submodule) []*submodule.Submodule {
// 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) {
if strings.Contains(strings.ToLower(s.Path), m.filter) && (!m.problemsOnly || hasProblem(s)) {
return true
}
for _, c := range s.Children {
@@ -139,3 +180,9 @@ func (m *Model) shown(s *submodule.Submodule) bool {
}
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
}
+75 -31
View File
@@ -23,26 +23,38 @@ 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
filter string // lower-case; empty if not filtering
cursor *submodule.Submodule
offset int // index of the first visible row
width int
height int
spinner string
// 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 m.root != nil {
m.cursor = m.root
return
}
m.cursor = m.firstNode()
}
@@ -54,10 +66,14 @@ 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
}
@@ -76,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.
@@ -104,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
}
@@ -120,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
}
}
@@ -179,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()
}
@@ -233,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 {
@@ -269,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
@@ -312,8 +367,12 @@ func (m Model) View() string {
lines = append(lines, m.nodeLine(r, nameColW, refW))
}
}
if len(rows) == 0 && m.filter != "" {
lines = append(lines, theme.Fg(m.theme.Muted).Width(m.width).Render(" no submodule matches"))
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))
@@ -416,22 +475,7 @@ func (m Model) nodeLine(r row, nameColW, refW int) string {
staged := s.Status != nil && s.Status.PinStaged
h := s.HiddenIssues()
stagedMark := staged && len(issues) > 0
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))
}
case staged:
status = seg(t.Info, theme.Staged)
default:
status = seg(t.Muted, theme.InSync)
}
status := t.StatusWord(s, base, m.spinner)
if stagedMark || collapsed && h.Staged > 0 {
status += pad(1) + seg(t.Info, t.Glyphs.Staged)
}
+186
View File
@@ -305,3 +305,189 @@ func equal(a, b []string) bool {
}
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")
}