Fetch in the background and follow changes in the repositories

- On startup, after the local state is shown, fetch the superproject and
  every initialized submodule (all remotes, prune, at most 8 at once).
  Fetches never prompt; failures show git's message. --no-fetch skips it,
  f fetches again.
- The header shows "fetching n/m", then the fetch age and failures; the
  detail pane shows each submodule's last fetch and fetch errors.
- The git provider polls each repository's metadata (refs, HEAD, index,
  .gitmodules) every 2 s and reports changes. The app reloads only what
  changed, keeping old values, expansion and cursor; loads are
  single-flight with coalesced reruns. Refresh is driven by the
  repositories, so a fetch in another shell updates the view like ours.
- r reloads everything (worktree edits are not watched).
- Docs: F50-F53, N1/N8 rewritten, D18-D20 (D12 superseded).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Simon Franken
2026-10-21 16:15:22 +02:00
co-authored by Claude Opus 5.5
parent 19c6f17eb2
commit 27f0c0b5d2
27 changed files with 1097 additions and 90 deletions
+5 -3
View File
@@ -1,7 +1,7 @@
# AGENTS.md # AGENTS.md
submarine: a Go + Bubble Tea TUI for repositories with many git submodules. submarine: a Go + Bubble Tea TUI for repositories with many git submodules.
Currently a prototype with a read-only git provider and mock data. Currently a prototype with a git provider (with background fetch) and mock data.
## Read first ## Read first
@@ -20,6 +20,7 @@ and decisions. Read the relevant document before changing behavior:
```sh ```sh
go run ./cmd/submarine [dir] # run on a repository (default: cwd) go run ./cmd/submarine [dir] # run on a repository (default: cwd)
go run ./cmd/submarine --mock # run on mock data 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 scripts/make-fixture.sh DIR # build a test superproject in DIR
go vet ./... go vet ./...
go test ./... go test ./...
@@ -34,8 +35,9 @@ gofmt -l . # must print nothing
- **Layering.** `internal/submodule` has no UI imports and decides what a - **Layering.** `internal/submodule` has no UI imports and decides what a
state means (`Status.Issues()`). `internal/tui/theme` decides how it looks. state means (`Status.Issues()`). `internal/tui/theme` decides how it looks.
Components never classify states or define colors themselves. Components never classify states or define colors themselves.
- **Data access** only through `submodule.Provider`. The git provider is - **Data access** only through `submodule.Provider` (and its optional
read-only and must never use the network. `Fetcher` / `Watcher`). The git provider only uses the network in
`Fetch`, and git must never prompt.
- **Colors** only from `theme.Theme`, ANSI 16 colors for meaning (see - **Colors** only from `theme.Theme`, ANSI 16 colors for meaning (see
`docs/ux-guide.md#colors`). Render segments on a background with a `base` `docs/ux-guide.md#colors`). Render segments on a background with a `base`
style (`LevelStyle(level, base)`, `Delta(pin, base)`). style (`LevelStyle(level, base)`, `Delta(pin, base)`).
+3 -2
View File
@@ -19,8 +19,9 @@ import (
func main() { func main() {
useMock := flag.Bool("mock", false, "use built-in mock data instead of a real repository") 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() { flag.Usage = func() {
fmt.Fprintln(flag.CommandLine.Output(), "usage: submarine [--mock] [dir]") fmt.Fprintln(flag.CommandLine.Output(), "usage: submarine [--mock] [--no-fetch] [dir]")
flag.PrintDefaults() flag.PrintDefaults()
} }
flag.Parse() flag.Parse()
@@ -55,7 +56,7 @@ func main() {
zone.NewGlobal() zone.NewGlobal()
defer zone.Close() defer zone.Close()
p := tea.NewProgram(tui.New(ctx, provider), tea.WithAltScreen(), tea.WithMouseCellMotion()) p := tea.NewProgram(tui.New(ctx, provider, tui.Options{Fetch: !*noFetch}), tea.WithAltScreen(), tea.WithMouseCellMotion())
if _, err := p.Run(); err != nil { if _, err := p.Run(); err != nil {
fmt.Fprintln(os.Stderr, err) fmt.Fprintln(os.Stderr, err)
os.Exit(1) os.Exit(1)
+6 -6
View File
@@ -18,13 +18,13 @@ bare commit hash.
## Status ## Status
**Prototype.** The UI is complete for browsing. It reads real repositories **Prototype.** The UI is complete for browsing. It reads real repositories
through a read-only git provider (`internal/submodule/git`): through a git provider (`internal/submodule/git`): `submarine [dir]`. It
`submarine [dir]`. `submarine --mock` shows the built-in mock data fetches in the background on startup (`--no-fetch` to skip; `f` to fetch
(`internal/submodule/mock`). Actions (`u`, `b`, `f`, `/`, `!`) only show a again) and follows changes made in other shells. `submarine --mock` shows
"not implemented" message. the built-in mock data (`internal/submodule/mock`). The actions `u`, `b`,
`/`, `!` only show a "not implemented" message.
Next steps, roughly in order: Next steps, roughly in order:
1. Actions: fetch, checkout pin (`submodule update`), bump pin to tip, init. 1. Actions: checkout pin (`submodule update`), bump pin to tip, init.
2. Filter (`/`) and "problems only" (`!`) views. 2. Filter (`/`) and "problems only" (`!`) views.
3. Refresh after actions and on demand.
+71 -5
View File
@@ -13,15 +13,19 @@ internal/submodule/ domain model; NO UI imports
model.go Repo, Submodule, Status, PinInfo, Commit, enums model.go Repo, Submodule, Status, PinInfo, Commit, enums
status.go Issues(), HiddenIssues(), Walk() status.go Issues(), HiddenIssues(), Walk()
provider.go Provider interface provider.go Provider interface
internal/submodule/git/ read-only provider using the git CLI internal/submodule/git/ provider using the git CLI (reads, fetch, watch)
exec.go git runner (environment, errors) exec.go git runner (environment, errors)
parse.go pure parsers for git output (status, numstat, …) parse.go pure parsers for git output (status, numstat, …)
provider.go Tree, Status, pin → ref resolution provider.go Tree, Status, pin → ref resolution
fetch.go Fetch (network), Fetchable
watch.go Watch: polls git metadata for changes
gitdir.go resolves .git dirs and gitdir: files
internal/submodule/mock/ fixture provider with artificial latency internal/submodule/mock/ fixture provider with artificial latency
internal/tui/ Bubble Tea application internal/tui/ Bubble Tea application
app.go root model: layout, message routing, loading app.go root model: layout, message routing, loading
keys.go key bindings (also feed the help view) keys.go key bindings (also feed the help view)
msgs.go treeLoadedMsg, statusLoadedMsg 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 theme/ colors, glyphs, issue words, change letters, level styles
tree/ tree component: rows, navigation, mouse, view tree/ tree component: rows, navigation, mouse, view
detail/ detail pane; changes.go: change preview detail/ detail pane; changes.go: change preview
@@ -47,6 +51,18 @@ type Provider interface {
} }
``` ```
Optional capabilities; the app type-asserts them:
```go
type Fetcher interface {
Fetchable(path string) bool // initialized? ("" = superproject)
Fetch(ctx context.Context, path string) error
}
type Watcher interface {
Watch(ctx context.Context) <-chan Change // {Path, Local, Structure}
}
```
- `Tree` returns the superproject and all submodules with `Parent` and - `Tree` returns the superproject and all submodules with `Parent` and
`Children` linked. `Status` fields are nil. `Children` linked. `Status` fields are nil.
- `Status` is called once per submodule, concurrently. It must be safe to - `Status` is called once per submodule, concurrently. It must be safe to
@@ -61,7 +77,7 @@ The mock provider (`internal/submodule/mock`) covers every state and sleeps
`internal/submodule/git` shells out to `git -C <dir> …` (see D14). `internal/submodule/git` shells out to `git -C <dir> …` (see D14).
- Environment: `GIT_TERMINAL_PROMPT=0`, `GIT_OPTIONAL_LOCKS=0` (reads never - Environment: `GIT_TERMINAL_PROMPT=0`, `GIT_OPTIONAL_LOCKS=0` (reads never
take `index.lock`), `LC_ALL=C`. No command touches the network. take `index.lock`), `LC_ALL=C`. Only `Fetch` uses the network.
- `Tree` reads each repository's `.gitmodules` (`git config -f - `Tree` reads each repository's `.gitmodules` (`git config -f
.gitmodules`) and recurses into initialized submodules. It records, per .gitmodules`) and recurses into initialized submodules. It records, per
path, the parent repository and the `.gitmodules` branch for `Status`. path, the parent repository and the `.gitmodules` branch for `Status`.
@@ -72,6 +88,22 @@ The mock provider (`internal/submodule/mock`) covers every state and sleeps
respect `ctx` (D16). respect `ctx` (D16).
- Superproject: branch from `symbolic-ref`, ahead/behind against - Superproject: branch from `symbolic-ref`, ahead/behind against
`@{upstream}`, "fetched" from the mtime of `FETCH_HEAD`. `@{upstream}`, "fetched" from the mtime of `FETCH_HEAD`.
- `Fetch`: `git fetch --all --prune --no-recurse-submodules --quiet` (we
fetch every submodule ourselves), at most 8 at once, 2 min timeout each.
To never prompt, `GIT_SSH_COMMAND="ssh -o BatchMode=yes"` is set unless
the user configured `GIT_SSH_COMMAND` or `core.sshCommand`. The error is
git's first line of output.
- `Watch` polls every 2 s (`WatchInterval`) and compares a fingerprint per
repository: names, mtimes and sizes of the git metadata files (D19).
- remote part: `FETCH_HEAD`, `refs/remotes/**`, `refs/tags/**`
- local part: `HEAD`, `index`, `MERGE_HEAD`, `packed-refs`,
`refs/heads/**`, `reftable/**`
- structure: the worktree `.gitmodules`, and whether `.git` exists
Git dirs are resolved from `.git` (directory, or `gitdir:` file for
submodules, plus `commondir` for worktrees) without running git. Our own
reads write nothing (`GIT_OPTIONAL_LOCKS=0`), so they never trigger a
change. Worktree file edits are not watched (`r` reloads).
## Loading flow ## Loading flow
@@ -80,7 +112,38 @@ The mock provider (`internal/submodule/mock`) covers every state and sleeps
calling `Status()` → `statusLoadedMsg`. calling `Status()` → `statusLoadedMsg`.
3. Each result is stored on the submodule (`Status` or `Err`); the view 3. Each result is stored on the submodule (`Status` or `Err`); the view
updates incrementally, including parents' hidden-issue counts. updates incrementally, including parents' hidden-issue counts.
4. The spinner stops ticking when nothing is loading. 4. After the first tree: the watcher starts (`changeMsg`), and the
background fetch starts one `tea.Cmd` per fetchable repository
(`fetchDoneMsg`).
5. The spinner ticks only while something loads or fetches.
### Fetch and refresh are decoupled (D20)
```
Fetch (network) ──writes refs──▶ .git/… ◀──writes refs── user's shell
│
Watch (polls every 2 s)
│ Change{Path, Local, Structure}
▼
app reloads Status / Tree
```
- `fetchDoneMsg` only updates the progress and `Submodule.FetchErr`; it
never reloads anything. The watcher sees the new refs and triggers the
reload, exactly as for a fetch made in another shell.
- `changeMsg`:
- submodule, remote only → reload its `Status`
- submodule, local → reload it and its direct children (their pins live
in its index)
- superproject, or `.gitmodules` anywhere → reload the tree; if local,
also every status
- A reloaded tree carries the old `Status`, `Err` and `FetchErr` over by
path, and `tree.ReplaceRoots` keeps expansion and the cursor, so a reload
doesn't flash spinners or move the selection.
- The `reloader` runs at most one load per key (a submodule path, or the
tree). A request during a load marks it dirty; it runs once more
afterwards. Results therefore never arrive out of order and bursts of
changes (a fetch writing many refs) coalesce.
## Tree component ## Tree component
@@ -114,7 +177,10 @@ go test ./...
- `internal/submodule`: issue classification and hidden-issue aggregation. - `internal/submodule`: issue classification and hidden-issue aggregation.
- `internal/submodule/git`: parser unit tests, and integration tests - `internal/submodule/git`: parser unit tests, and integration tests
against a fixture repository built by `scripts/make-fixture.sh` (skipped against a fixture repository built by `scripts/make-fixture.sh` (skipped
if `git` is not installed). if `git` is not installed). Fetch and watch tests build their own
fixture, since they change it; the fixture's remotes are local bare
repositories, so fetching works offline.
- `internal/tui`: `reloader`.
- `internal/tui/tree`: navigation rules. Tests need `zone.NewGlobal()` (see - `internal/tui/tree`: navigation rules. Tests need `zone.NewGlobal()` (see
`TestMain`). `TestMain`).
+38 -1
View File
@@ -90,7 +90,7 @@ artificial latency.
**Consequences.** A git backend can be added without UI changes; the UI **Consequences.** A git backend can be added without UI changes; the UI
stays responsive with many submodules. stays responsive with many submodules.
## D12 — No network on startup ## D12 — No network on startup (superseded by D18)
**Decision.** Work from local refs and show their age; fetching is an **Decision.** Work from local refs and show their age; fetching is an
explicit action (`f`). explicit action (`f`).
@@ -146,3 +146,40 @@ Dirt inside nested submodules is ignored (D7); unstaged moves show as
removed + untracked. removed + untracked.
**Consequences.** One extra `git diff` per dirty submodule, none for clean **Consequences.** One extra `git diff` per dirty submodule, none for clean
ones. Not included: staged/unstaged markers, diff bodies, stash count. ones. Not included: staged/unstaged markers, diff bodies, stash count.
## D18 — Background fetch on startup
**Context.** Without fetching, every answer is only as fresh as the user's
last `git fetch`, which they rarely run in every submodule.
**Decision.** Supersedes D12. Show the local state at once, then fetch the
superproject and every initialized submodule in the background (all
remotes, prune, no recursion: we fetch each repository ourselves, at most 8
at once). Fetches never prompt; they fail with git's message instead.
`--no-fetch` disables it, `f` repeats it.
**Consequences.** Startup still doesn't wait for the network (N1). A
failed fetch is shown per submodule and counted in the header; it is not an
issue (D3).
## D19 — Watch git metadata by polling
**Context.** The view should follow changes made outside the application.
File system events (fsnotify) need a dependency, one watch per directory
(hundreds of repositories × ref directories hit inotify limits) and don't
work on WSL's Windows drives or network file systems.
**Decision.** Poll every 2 s: compare names, mtimes and sizes of each
repository's git metadata (`FETCH_HEAD`, refs, `HEAD`, `index`, …),
split into a remote, a local and a structure part.
**Consequences.** Works everywhere, no dependency; a few `stat`s per
repository per poll; changes appear within about 2 s. Edits of worktree
files are not detected (that would mean running `git status` everywhere
every poll); `r` reloads on demand.
## D20 — Refresh is driven by the repositories, not by our fetch
**Decision.** A finished fetch only updates its progress. Reloading is
triggered solely by the watcher, so our own fetch and a fetch in another
shell take the same path. Reloads keep the old state visible, run at most
once per submodule at a time and coalesce bursts.
**Consequences.** One mechanism to test and trust. After our fetch the
data updates up to one poll interval later; the header shows "fetched just
now" right away.
+23 -5
View File
@@ -85,13 +85,28 @@ commit hashes. It doesn't answer the questions users actually have:
- **F41** Mouse: click a row to select it, click `▸`/`▾` to toggle, scroll - **F41** Mouse: click a row to select it, click `▸`/`▾` to toggle, scroll
wheel moves the selection. wheel moves the selection.
### 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.
- **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
failed, git's message.
- **F52** The view follows changes in the repositories, no matter who made
them: a fetch, commit, checkout or `git add` in another shell shows up
within about two seconds. Refreshing is driven by the repositories, not by
the application's own fetch.
- **F53** `f` fetches again; `r` reloads everything (e.g. after editing
files, which is not watched).
### Actions (planned, not implemented) ### Actions (planned, not implemented)
| Key | Action | | Key | Action |
|---|---| |---|---|
| `u` | Check out the pinned commit (`git submodule update`) | | `u` | Check out the pinned commit (`git submodule update`) |
| `b` | Bump the pin to the tip of its ref | | `b` | Bump the pin to the tip of its ref |
| `f` | Fetch remotes |
| `/` | Filter by name/path | | `/` | Filter by name/path |
| `!` | Show only submodules with issues | | `!` | Show only submodules with issues |
@@ -100,12 +115,13 @@ commit hashes. It doesn't answer the questions users actually have:
```sh ```sh
submarine [dir] # the repository containing dir (default: current directory) submarine [dir] # the repository containing dir (default: current directory)
submarine --mock # built-in mock data submarine --mock # built-in mock data
submarine --no-fetch [dir] # no background fetch on startup
``` ```
## Non-functional requirements ## Non-functional requirements
- **N1** Startup must not block on the network. The tool works from local - **N1** Startup must not block on the network. The tool shows the local
refs and shows how old they are ("fetched 2m ago"); fetching is explicit. state at once and fetches in the background (F50).
- **N2** Loading many submodules must not freeze the UI (async, per - **N2** Loading many submodules must not freeze the UI (async, per
submodule). submodule).
- **N3** Colors follow the user's terminal theme (ANSI 16 colors); the UI - **N3** Colors follow the user's terminal theme (ANSI 16 colors); the UI
@@ -116,5 +132,7 @@ submarine --mock # built-in mock data
- **N6** Works in narrow terminals (detail pane hides below 90 columns). - **N6** Works in narrow terminals (detail pane hides below 90 columns).
- **N7** The UI is independent of the data source: the mock and the git - **N7** The UI is independent of the data source: the mock and the git
provider are interchangeable without UI changes. provider are interchangeable without UI changes.
- **N8** The git provider is read-only and never uses the network; it uses - **N8** The git provider only uses the network in `Fetch`, and never
the `git` command line, so the user's git configuration applies. 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.
+5
View File
@@ -33,6 +33,11 @@ in several at once.
| `Pinned`, `CheckedOut`, `Tip` | Commits (SHA, subject, author, time) | | `Pinned`, `CheckedOut`, `Tip` | Commits (SHA, subject, author, time) |
| `Between` | Commits that make up the pin ↔ tip difference | | `Between` | Commits that make up the pin ↔ tip difference |
| `Changes` | Changed files of the worktree (nil if clean), see below | | `Changes` | Changed files of the worktree (nil if clean), see below |
| `FetchedAt` | Last fetch of the submodule (`FETCH_HEAD` mtime); zero if never |
`Submodule.FetchErr` is set if the application's last fetch of the
submodule failed. Like loading errors, it is not an issue kind: a failed
fetch says nothing about the submodule's state.
`Status == nil` means "still loading". `Submodule.Err` is set if loading `Status == nil` means "still loading". `Submodule.Err` is set if loading
failed. failed.
+20 -6
View File
@@ -63,7 +63,8 @@ indent ▸ name [n] status ref delta
`detached HEAD on pin` as information `detached HEAD on pin` as information
3. `Changes` (only with uncommitted changes), see 3. `Changes` (only with uncommitted changes), see
[Change preview](#change-preview) [Change preview](#change-preview)
4. `Ref` with distance and `via <rule>` 4. `Ref` with distance and `via <rule>`; `Fetched <age>` of the submodule;
`fetch failed: <git's message>` (yellow word) if the last fetch failed
5. `Pinned`, `Checkout`, `Tip`: SHA + subject; the checkout SHA is yellow if it 5. `Pinned`, `Checkout`, `Tip`: SHA + subject; the checkout SHA is yellow if it
differs from the pin differs from the pin
6. "Pin is n commits behind/ahead of …" / "Pin and … have diverged": commit list 6. "Pin is n commits behind/ahead of …" / "Pin and … have diverged": commit list
@@ -115,12 +116,23 @@ Changes 5 files · 2M 1A 1D 1R · +37 −12
- Untracked directories are shown as one entry (`tmp/`). - Untracked directories are shown as one entry (`tmp/`).
### Header ### Header
`path branch ↑↓ · n submodules · <count> <issue>… · n pin staged · n loading · fetched <age>` `path branch ↑↓ · n submodules · <count> <issue>… · n pin staged · n loading · <fetch>`
Issue counts are ordered by severity (unpushed, conflict, missing, off pin, Issue counts are ordered by severity (unpushed, conflict, missing, off pin,
changed), each in its level color. Zero counts are omitted. `<age>` is changed), each in its level color. Zero counts are omitted. A detached
`never` if the superproject has never been fetched; a detached superproject superproject shows `(detached)` as its branch.
shows `(detached)` as its branch.
`<fetch>` on the right:
| State | Shows |
|---|---|
| fetching | spinner + `fetching 12/174` (repositories done / total) |
| done | `fetched just now`, then the age (`fetched 5m ago`) |
| some failed | `fetched just now · 3 failed` (count in yellow) |
| never fetched | `fetched never` |
The age is the newer of the superproject's `FETCH_HEAD` and the end of the
application's own fetch.
## Colors ## Colors
@@ -171,7 +183,9 @@ colors in components.
| `pgup`, `pgdn` | Move by one screen | | `pgup`, `pgdn` | Move by one screen |
| `?` | Toggle full help | | `?` | Toggle full help |
| `q`, `ctrl+c` | Quit | | `q`, `ctrl+c` | Quit |
| `u`, `b`, `f`, `/`, `!` | Planned actions (see requirements) | | `f` | Fetch all repositories (does nothing while a fetch runs) |
| `r` | Reload everything (e.g. after editing files) |
| `u`, `b`, `/`, `!` | Planned actions (see requirements) |
"First child" always means the first child **as displayed** (children are "First child" always means the first child **as displayed** (children are
grouped by directory). grouped by directory).
+25 -7
View File
@@ -4,9 +4,9 @@ import (
"bytes" "bytes"
"context" "context"
"errors" "errors"
"fmt"
"os" "os"
"os/exec" "os/exec"
"slices"
"strings" "strings"
) )
@@ -18,22 +18,40 @@ var gitEnv = append(os.Environ(),
"LC_ALL=C", "LC_ALL=C",
) )
// gitError is a failed git command; Stderr is git's message.
type gitError struct {
Args []string
Stderr string
Err error
}
func (e *gitError) Error() string {
msg := e.Stderr
if msg == "" {
msg = e.Err.Error()
}
return "git " + strings.Join(e.Args, " ") + ": " + msg
}
func (e *gitError) Unwrap() error { return e.Err }
// run executes git in dir and returns stdout with the trailing newline // run executes git in dir and returns stdout with the trailing newline
// removed. The error includes git's stderr. // removed. The error includes git's stderr.
func run(ctx context.Context, dir string, args ...string) (string, error) { func run(ctx context.Context, dir string, args ...string) (string, error) {
return runEnv(ctx, dir, nil, args...)
}
// runEnv is run with additional environment variables.
func runEnv(ctx context.Context, dir string, env []string, args ...string) (string, error) {
cmd := exec.CommandContext(ctx, "git", append([]string{"-C", dir}, args...)...) cmd := exec.CommandContext(ctx, "git", append([]string{"-C", dir}, args...)...)
cmd.Env = gitEnv cmd.Env = slices.Concat(gitEnv, env) // a fresh slice: run is called concurrently
var stdout, stderr bytes.Buffer var stdout, stderr bytes.Buffer
cmd.Stdout, cmd.Stderr = &stdout, &stderr cmd.Stdout, cmd.Stderr = &stdout, &stderr
if err := cmd.Run(); err != nil { if err := cmd.Run(); err != nil {
if ctx.Err() != nil { if ctx.Err() != nil {
return "", ctx.Err() return "", ctx.Err()
} }
msg := strings.TrimSpace(stderr.String()) return "", &gitError{Args: args, Stderr: strings.TrimSpace(stderr.String()), Err: err}
if msg == "" {
msg = err.Error()
}
return "", fmt.Errorf("git %s: %s", strings.Join(args, " "), msg)
} }
return strings.TrimRight(stdout.String(), "\n"), nil return strings.TrimRight(stdout.String(), "\n"), nil
} }
+79
View File
@@ -0,0 +1,79 @@
package git
import (
"context"
"errors"
"fmt"
"os"
"strings"
"time"
)
// fetchTimeout bounds a single fetch, e.g. against an unreachable host.
const fetchTimeout = 2 * time.Minute
// maxFetches bounds concurrent fetches; they wait on the network, not the
// CPU.
const maxFetches = 8
// sshBatchEnv makes ssh fail instead of asking for a password or host key,
// which would corrupt the terminal UI. It is nil if the user configured
// their own ssh command.
func sshBatchEnv(root string) []string {
if os.Getenv("GIT_SSH_COMMAND") != "" {
return nil
}
if out, ok, _ := try(context.Background(), root, "config", "core.sshCommand"); ok && out != "" {
return nil
}
return []string{"GIT_SSH_COMMAND=ssh -o BatchMode=yes"}
}
// Fetchable reports whether path ("" is the superproject) is a checked-out
// repository.
func (p *Provider) Fetchable(path string) bool {
_, ok := p.repoDir(path)
return ok
}
// repoDir returns the worktree of path if it is initialized.
func (p *Provider) repoDir(path string) (string, bool) {
if path == "" {
return p.root, true
}
p.mu.RLock()
in, ok := p.mods[path]
p.mu.RUnlock()
if !ok || !initialized(in.dir) {
return "", false
}
return in.dir, true
}
// Fetch fetches all remotes of one repository. Submodules are fetched
// separately, not recursively. The error is git's first line of output.
func (p *Provider) Fetch(ctx context.Context, path string) error {
dir, ok := p.repoDir(path)
if !ok {
return fmt.Errorf("%s is not initialized", path)
}
select {
case p.fetchSem <- struct{}{}:
defer func() { <-p.fetchSem }()
case <-ctx.Done():
return ctx.Err()
}
ctx, cancel := context.WithTimeout(ctx, fetchTimeout)
defer cancel()
_, err := runEnv(ctx, dir, p.sshEnv, "fetch", "--all", "--prune", "--no-recurse-submodules", "--quiet")
var ge *gitError
switch {
case errors.Is(err, context.DeadlineExceeded):
return fmt.Errorf("timed out after %s", fetchTimeout)
case errors.As(err, &ge) && ge.Stderr != "":
line, _, _ := strings.Cut(ge.Stderr, "\n")
return errors.New(strings.TrimPrefix(line, "fatal: "))
}
return err
}
+161
View File
@@ -0,0 +1,161 @@
package git
import (
"context"
"os/exec"
"path/filepath"
"strings"
"testing"
"time"
"submarine/internal/submodule"
)
// ownFixture builds a fixture the test may modify. It returns the provider
// (after Tree) and the fixture root (superproject in root/super, work
// clones of the remotes in root/work).
func ownFixture(t *testing.T) (*Provider, string) {
t.Helper()
if _, err := exec.LookPath("git"); err != nil {
t.Skip("git not available")
}
root := filepath.Join(t.TempDir(), "fx")
if err := makeFixture(root); err != nil {
t.Fatal(err)
}
p, err := New(filepath.Join(root, "super"))
if err != nil {
t.Fatal(err)
}
if _, err := p.Tree(context.Background()); err != nil {
t.Fatal(err)
}
return p, root
}
func git(t *testing.T, dir string, args ...string) {
t.Helper()
args = append([]string{"-C", dir, "-c", "user.name=t", "-c", "user.email=t@t"}, args...)
if out, err := exec.Command("git", args...).CombinedOutput(); err != nil {
t.Fatalf("git %v: %v\n%s", args, err, out)
}
}
// pushUpstream adds a commit to a fixture remote, as if someone else pushed.
func pushUpstream(t *testing.T, root, remote string) {
t.Helper()
work := filepath.Join(root, "work", remote)
git(t, work, "fetch", "-q")
git(t, work, "reset", "-q", "--hard", "origin/main")
git(t, work, "commit", "--allow-empty", "-m", "Upstream change")
git(t, work, "push", "-q", "origin", "main")
}
func TestFetch(t *testing.T) {
p, root := ownFixture(t)
ctx := context.Background()
before, err := p.Status(ctx, "src/core")
if err != nil {
t.Fatal(err)
}
pushUpstream(t, root, "core")
if err := p.Fetch(ctx, "src/core"); err != nil {
t.Fatalf("Fetch: %v", err)
}
after, err := p.Status(ctx, "src/core")
if err != nil {
t.Fatal(err)
}
if after.Pin.Behind <= before.Pin.Behind {
t.Errorf("after fetch: ↓%d, want more than ↓%d", after.Pin.Behind, before.Pin.Behind)
}
if after.FetchedAt.IsZero() {
t.Error("after fetch: FetchedAt is zero")
}
if err := p.Fetch(ctx, ""); err != nil {
t.Errorf("Fetch(superproject without remotes): %v", err)
}
if p.Fetchable("libs/missing") {
t.Error("Fetchable(libs/missing) = true for an uninitialized submodule")
}
if err := p.Fetch(ctx, "libs/missing"); err == nil {
t.Error("Fetch(libs/missing): want error")
}
}
func TestFetchError(t *testing.T) {
p, root := ownFixture(t)
git(t, filepath.Join(root, "super", "src", "ui"), "remote", "set-url", "origin", filepath.Join(root, "nowhere.git"))
err := p.Fetch(context.Background(), "src/ui")
if err == nil {
t.Fatal("Fetch with a bad remote: want error")
}
if strings.Contains(err.Error(), "\n") || strings.HasPrefix(err.Error(), "git ") {
t.Errorf("error should be git's first line only, got %q", err)
}
}
// next waits for a change or fails after timeout.
func next(t *testing.T, ch <-chan submodule.Change) submodule.Change {
t.Helper()
select {
case c := <-ch:
return c
case <-time.After(3 * time.Second):
t.Fatal("no change reported")
return submodule.Change{}
}
}
func TestWatch(t *testing.T) {
p, root := ownFixture(t)
super := filepath.Join(root, "super")
p.WatchInterval = 20 * time.Millisecond
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
ch := p.Watch(ctx)
// our own reads must not look like changes
for _, path := range []string{"src/core", "libs/work", "libs/conflict"} {
if _, err := p.Status(ctx, path); err != nil {
t.Fatal(err)
}
}
select {
case c := <-ch:
t.Fatalf("change while idle: %+v", c)
case <-time.After(200 * time.Millisecond):
}
// a fetch in another shell
pushUpstream(t, root, "net")
git(t, filepath.Join(super, "src", "net"), "fetch", "-q")
if c := next(t, ch); c != (submodule.Change{Path: "src/net"}) {
t.Errorf("after fetch: %+v, want remote change of src/net", c)
}
// a commit in another shell
git(t, filepath.Join(super, "libs", "store"), "commit", "-q", "--allow-empty", "-m", "Local")
if c := next(t, ch); c != (submodule.Change{Path: "libs/store", Local: true}) {
t.Errorf("after commit: %+v, want local change of libs/store", c)
}
cancel()
for range ch { // closed after cancel
}
}
func TestGitDirs(t *testing.T) {
p, _ := loadFixture(t)
gd, cd, err := gitDirs(p.root)
if err != nil || gd != filepath.Join(p.root, ".git") || cd != gd {
t.Errorf("superproject: %q, %q, %v", gd, cd, err)
}
gd, cd, err = gitDirs(filepath.Join(p.root, "src", "core"))
if err != nil || gd != filepath.Join(p.root, ".git", "modules", "src", "core") || cd != gd {
t.Errorf("submodule: %q, %q, %v", gd, cd, err)
}
}
+43
View File
@@ -0,0 +1,43 @@
package git
import (
"errors"
"os"
"path/filepath"
"strings"
)
// gitDirs returns the git directory of the worktree at dir and its common
// directory (they differ only for linked worktrees). A submodule's .git is
// a file "gitdir: <path>".
func gitDirs(dir string) (gitDir, commonDir string, err error) {
dotGit := filepath.Join(dir, ".git")
fi, err := os.Stat(dotGit)
if err != nil {
return "", "", err
}
gitDir = dotGit
if !fi.IsDir() {
b, err := os.ReadFile(dotGit)
if err != nil {
return "", "", err
}
p, ok := strings.CutPrefix(strings.TrimSpace(string(b)), "gitdir:")
if !ok {
return "", "", errors.New(dotGit + ": not a gitdir file")
}
gitDir = joinPath(dir, strings.TrimSpace(p))
}
commonDir = gitDir
if b, err := os.ReadFile(filepath.Join(gitDir, "commondir")); err == nil {
commonDir = joinPath(gitDir, strings.TrimSpace(string(b)))
}
return gitDir, commonDir, nil
}
func joinPath(base, p string) string {
if !filepath.IsAbs(p) {
p = filepath.Join(base, p)
}
return filepath.Clean(p)
}
+19 -4
View File
@@ -14,6 +14,7 @@ import (
"slices" "slices"
"strings" "strings"
"sync" "sync"
"time"
"submarine/internal/submodule" "submarine/internal/submodule"
) )
@@ -27,8 +28,13 @@ const maxBetween = 50
// Provider reads a superproject and its submodules with git. // Provider reads a superproject and its submodules with git.
type Provider struct { type Provider struct {
root string // absolute superproject root root string // absolute superproject root
sem chan struct{} // bounds concurrent Status calls sem chan struct{} // bounds concurrent Status calls
fetchSem chan struct{} // bounds concurrent fetches
sshEnv []string // extra environment for fetches
// WatchInterval is how often Watch checks for changes.
WatchInterval time.Duration
mu sync.RWMutex mu sync.RWMutex
mods map[string]info // by Submodule.Path; filled by Tree mods map[string]info // by Submodule.Path; filled by Tree
@@ -49,8 +55,11 @@ func New(dir string) (*Provider, error) {
return nil, fmt.Errorf("%s is not a git repository", dir) return nil, fmt.Errorf("%s is not a git repository", dir)
} }
return &Provider{ return &Provider{
root: root, root: root,
sem: make(chan struct{}, runtime.NumCPU()), sem: make(chan struct{}, runtime.NumCPU()),
fetchSem: make(chan struct{}, maxFetches),
sshEnv: sshBatchEnv(root),
WatchInterval: 2 * time.Second,
}, nil }, nil
} }
@@ -168,6 +177,12 @@ func status(ctx context.Context, in info) (*submodule.Status, error) {
return st, nil return st, nil
} }
if gitDir, _, err := gitDirs(in.dir); err == nil {
if fi, err := os.Stat(filepath.Join(gitDir, "FETCH_HEAD")); err == nil {
st.FetchedAt = fi.ModTime()
}
}
// checkout and worktree // checkout and worktree
head, err := run(ctx, in.dir, "rev-parse", "HEAD") head, err := run(ctx, in.dir, "rev-parse", "HEAD")
if err != nil { if err != nil {
+12 -5
View File
@@ -27,11 +27,8 @@ func TestMain(m *testing.M) {
} }
defer os.RemoveAll(tmp) defer os.RemoveAll(tmp)
if _, err := exec.LookPath("git"); err == nil { if _, err := exec.LookPath("git"); err == nil {
_, file, _, _ := runtime.Caller(0) if err := makeFixture(filepath.Join(tmp, "fx")); err != nil {
script := filepath.Join(filepath.Dir(file), "..", "..", "..", "scripts", "make-fixture.sh") panic(err)
out, err := exec.Command("bash", script, filepath.Join(tmp, "fx")).CombinedOutput()
if err != nil {
panic("make-fixture.sh: " + err.Error() + "\n" + string(out))
} }
fixtureDir = filepath.Join(tmp, "fx", "super") fixtureDir = filepath.Join(tmp, "fx", "super")
} }
@@ -40,6 +37,16 @@ func TestMain(m *testing.M) {
os.Exit(code) os.Exit(code)
} }
// makeFixture runs scripts/make-fixture.sh; the superproject is dir/super.
func makeFixture(dir string) error {
_, file, _, _ := runtime.Caller(0)
script := filepath.Join(filepath.Dir(file), "..", "..", "..", "scripts", "make-fixture.sh")
if out, err := exec.Command("bash", script, dir).CombinedOutput(); err != nil {
return fmt.Errorf("make-fixture.sh: %v\n%s", err, out)
}
return nil
}
func loadFixture(t *testing.T) (*Provider, *submodule.Repo) { func loadFixture(t *testing.T) (*Provider, *submodule.Repo) {
t.Helper() t.Helper()
if fixtureDir == "" { if fixtureDir == "" {
+123
View File
@@ -0,0 +1,123 @@
package git
import (
"context"
"fmt"
"io/fs"
"os"
"path/filepath"
"strings"
"time"
"submarine/internal/submodule"
)
// fingerprint summarizes the git metadata of one repository. Each part is
// a list of file names with modification time and size; a change in a part
// means something in that area changed.
type fingerprint struct {
remote string // FETCH_HEAD, remote-tracking branches, tags
local string // HEAD, index, local branches, packed refs, merge state
structure string // .gitmodules and whether the repository is checked out
}
// Watch polls the git metadata of the superproject and all submodules every
// WatchInterval and reports the repositories that changed. Polling (instead
// of file system events) works on every file system, including WSL's
// Windows drives, and needs no watch handles.
func (p *Provider) Watch(ctx context.Context) <-chan submodule.Change {
ch := make(chan submodule.Change, 16)
go func() {
defer close(ch)
prev := p.fingerprints()
t := time.NewTicker(p.WatchInterval)
defer t.Stop()
for {
select {
case <-ctx.Done():
return
case <-t.C:
}
cur := p.fingerprints()
for path, f := range cur {
old, ok := prev[path]
if !ok || old == f {
continue // new repositories only get a baseline
}
c := submodule.Change{
Path: path,
Local: old.local != f.local || old.structure != f.structure,
Structure: old.structure != f.structure,
}
select {
case ch <- c:
case <-ctx.Done():
return
}
}
prev = cur
}
}()
return ch
}
// fingerprints returns the fingerprint of every known repository, keyed by
// submodule path ("" is the superproject).
func (p *Provider) fingerprints() map[string]fingerprint {
dirs := map[string]string{"": p.root}
p.mu.RLock()
for path, in := range p.mods {
dirs[path] = in.dir
}
p.mu.RUnlock()
fps := make(map[string]fingerprint, len(dirs))
for path, dir := range dirs {
fps[path] = fingerprintOf(dir)
}
return fps
}
func fingerprintOf(dir string) fingerprint {
var f fingerprint
var structure strings.Builder
stamp(&structure, filepath.Join(dir, ".gitmodules"))
gitDir, common, err := gitDirs(dir)
if err != nil {
f.structure = structure.String() + "not checked out"
return f
}
f.structure = structure.String()
var remote, local strings.Builder
stamp(&remote, filepath.Join(gitDir, "FETCH_HEAD"))
stampTree(&remote, filepath.Join(common, "refs", "remotes"))
stampTree(&remote, filepath.Join(common, "refs", "tags"))
for _, name := range []string{"HEAD", "index", "MERGE_HEAD"} {
stamp(&local, filepath.Join(gitDir, name))
}
stamp(&local, filepath.Join(common, "packed-refs"))
stampTree(&local, filepath.Join(common, "refs", "heads"))
stampTree(&local, filepath.Join(common, "reftable")) // reftable ref storage
f.remote, f.local = remote.String(), local.String()
return f
}
// stamp appends the name, modification time and size of a file; nothing if
// it doesn't exist.
func stamp(b *strings.Builder, name string) {
if fi, err := os.Stat(name); err == nil {
fmt.Fprintf(b, "%s %d %d\n", name, fi.ModTime().UnixNano(), fi.Size())
}
}
// stampTree stamps every file below dir.
func stampTree(b *strings.Builder, dir string) {
filepath.WalkDir(dir, func(name string, d fs.DirEntry, err error) error {
if err == nil && !d.IsDir() {
stamp(b, name)
}
return nil
})
}
+50 -9
View File
@@ -4,9 +4,11 @@ package mock
import ( import (
"context" "context"
"errors"
"fmt" "fmt"
"math/rand/v2" "math/rand/v2"
"path" "path"
"sync"
"time" "time"
"submarine/internal/submodule" "submarine/internal/submodule"
@@ -15,7 +17,9 @@ import (
// Provider serves a fixed fixture tree. // Provider serves a fixed fixture tree.
type Provider struct { type Provider struct {
MinDelay, MaxDelay time.Duration MinDelay, MaxDelay time.Duration
statuses map[string]*submodule.Status
mu sync.RWMutex // Tree can run again (reload) while Status runs
statuses map[string]*submodule.Status
} }
func New() *Provider { func New() *Provider {
@@ -32,6 +36,8 @@ type node struct {
} }
func (p *Provider) Tree(ctx context.Context) (*submodule.Repo, error) { func (p *Provider) Tree(ctx context.Context) (*submodule.Repo, error) {
p.mu.Lock()
defer p.mu.Unlock()
p.statuses = map[string]*submodule.Status{} p.statuses = map[string]*submodule.Status{}
repo := &submodule.Repo{ repo := &submodule.Repo{
Path: "~/coding/my_project", Path: "~/coding/my_project",
@@ -57,6 +63,9 @@ func (p *Provider) build(nodes []node, parent *submodule.Submodule) []*submodule
Parent: parent, Parent: parent,
} }
m.Children = p.build(n.children, m) m.Children = p.build(n.children, m)
if n.status.Presence == submodule.PresenceOK && n.status.FetchedAt.IsZero() {
n.status.FetchedAt = now.Add(-2 * time.Minute)
}
p.statuses[full] = n.status p.statuses[full] = n.status
out = append(out, m) out = append(out, m)
} }
@@ -64,22 +73,54 @@ func (p *Provider) build(nodes []node, parent *submodule.Submodule) []*submodule
} }
func (p *Provider) Status(ctx context.Context, path string) (*submodule.Status, error) { func (p *Provider) Status(ctx context.Context, path string) (*submodule.Status, error) {
delay := p.MinDelay if err := p.sleep(ctx, p.MinDelay, p.MaxDelay); err != nil {
if span := p.MaxDelay - p.MinDelay; span > 0 { return nil, err
delay += rand.N(span)
}
select {
case <-time.After(delay):
case <-ctx.Done():
return nil, ctx.Err()
} }
p.mu.RLock()
s, ok := p.statuses[path] s, ok := p.statuses[path]
p.mu.RUnlock()
if !ok { if !ok {
return nil, fmt.Errorf("unknown submodule %q", path) return nil, fmt.Errorf("unknown submodule %q", path)
} }
return s, nil return s, nil
} }
// Fetchable is true for the superproject and initialized submodules.
func (p *Provider) Fetchable(path string) bool {
if path == "" {
return true
}
p.mu.RLock()
defer p.mu.RUnlock()
s, ok := p.statuses[path]
return ok && s.Presence == submodule.PresenceOK
}
// Fetch pretends to fetch; one submodule always fails. It changes no data.
func (p *Provider) Fetch(ctx context.Context, path string) error {
if err := p.sleep(ctx, 300*time.Millisecond, 2*time.Second); err != nil {
return err
}
if path == "src/my_submodule_3" {
return errors.New("git@github.com: Permission denied (publickey).")
}
return nil
}
// sleep waits a random duration in [min, max) or until ctx is done.
func (p *Provider) sleep(ctx context.Context, min, max time.Duration) error {
d := min
if span := max - min; span > 0 {
d += rand.N(span)
}
select {
case <-time.After(d):
return nil
case <-ctx.Done():
return ctx.Err()
}
}
// --- fixture ----------------------------------------------------------------- // --- fixture -----------------------------------------------------------------
var now = time.Now() var now = time.Now()
+4 -2
View File
@@ -29,6 +29,7 @@ type Submodule struct {
Children []*Submodule Children []*Submodule
Status *Status // nil while loading Status *Status // nil while loading
Err error // set if loading the status failed Err error // set if loading the status failed
FetchErr error // set if the last fetch failed; not an issue
} }
// Presence describes whether the submodule exists on disk. // Presence describes whether the submodule exists on disk.
@@ -177,6 +178,7 @@ type Status struct {
Pinned *Commit Pinned *Commit
CheckedOut *Commit CheckedOut *Commit
Tip *Commit Tip *Commit
Between []Commit // commits that make up the pin <-> tip difference Between []Commit // commits that make up the pin <-> tip difference
Changes *Changes // nil if the worktree is clean or the details are unknown Changes *Changes // nil if the worktree is clean or the details are unknown
FetchedAt time.Time // last fetch of the submodule; zero if never
} }
+24
View File
@@ -8,3 +8,27 @@ type Provider interface {
Tree(ctx context.Context) (*Repo, error) Tree(ctx context.Context) (*Repo, error)
Status(ctx context.Context, path string) (*Status, error) Status(ctx context.Context, path string) (*Status, error)
} }
// Fetcher is implemented by providers that can download remote refs. Path
// "" is the superproject. Fetch must be safe to call concurrently and must
// never prompt the user.
type Fetcher interface {
// Fetchable reports whether path is a repository that can be fetched
// (e.g. not an uninitialized submodule).
Fetchable(path string) bool
Fetch(ctx context.Context, path string) error
}
// Watcher is implemented by providers that notice changes made outside the
// application, e.g. a fetch or commit in another shell. The channel is
// closed when ctx is done.
type Watcher interface {
Watch(ctx context.Context) <-chan Change
}
// Change reports that the git metadata of a repository changed.
type Change struct {
Path string // "" is the superproject
Local bool // HEAD, index or local branches changed, not only remote refs
Structure bool // .gitmodules changed: submodules may have been added or removed
}
+217 -26
View File
@@ -4,6 +4,7 @@ package tui
import ( import (
"context" "context"
"time"
"github.com/charmbracelet/bubbles/key" "github.com/charmbracelet/bubbles/key"
"github.com/charmbracelet/bubbles/spinner" "github.com/charmbracelet/bubbles/spinner"
@@ -26,16 +27,29 @@ const (
detailWidthRatio = 0.4 detailWidthRatio = 0.4
) )
// Options configure the application.
type Options struct {
Fetch bool // fetch all repositories in the background on startup
}
type Model struct { type Model struct {
ctx context.Context ctx context.Context
provider submodule.Provider 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 theme theme.Theme
keys keyMap keys keyMap
repo *submodule.Repo repo *submodule.Repo
byPath map[string]*submodule.Submodule byPath map[string]*submodule.Submodule
loading int err error
err error
loads reloader
reloadStatuses bool // the running or pending tree load also reloads every status
changes <-chan submodule.Change
fetch header.Fetch
ticking bool // a spinner tick is scheduled
tree tree.Model tree tree.Model
detail detail.Model detail detail.Model
@@ -46,23 +60,30 @@ type Model struct {
showDetail bool showDetail bool
} }
func New(ctx context.Context, p submodule.Provider) Model { func New(ctx context.Context, p submodule.Provider, opts Options) Model {
t := theme.Default() t := theme.Default()
k := defaultKeys() k := defaultKeys()
sp := spinner.New(spinner.WithSpinner(spinner.MiniDot)) sp := spinner.New(spinner.WithSpinner(spinner.MiniDot))
return Model{ m := Model{
ctx: ctx, ctx: ctx,
provider: p, provider: p,
opts: opts,
theme: t, theme: t,
keys: k, keys: k,
loads: newReloader(),
ticking: true, // Init schedules the first tick
tree: tree.New(t), tree: tree.New(t),
detail: detail.New(t), detail: detail.New(t),
footer: footer.New(t, k), footer: footer.New(t, k),
spinner: sp, spinner: sp,
} }
m.fetcher, _ = p.(submodule.Fetcher)
m.watcher, _ = p.(submodule.Watcher)
return m
} }
func (m Model) Init() tea.Cmd { func (m Model) Init() tea.Cmd {
m.loads.start(treeKey)
return tea.Batch(m.loadTree(), m.spinner.Tick) return tea.Batch(m.loadTree(), m.spinner.Tick)
} }
@@ -80,6 +101,74 @@ func (m Model) loadStatus(path string) tea.Cmd {
} }
} }
// reloadTree loads the tree again, unless a load is running (then it runs
// once more afterwards). With statuses, every status is reloaded too.
func (m *Model) reloadTree(statuses bool) tea.Cmd {
m.reloadStatuses = m.reloadStatuses || statuses
if !m.loads.start(treeKey) {
return nil
}
return m.loadTree()
}
// reloadStatus loads one status again; the old one stays visible meanwhile.
func (m *Model) reloadStatus(path string) tea.Cmd {
if !m.loads.start(path) {
return nil
}
return m.loadStatus(path)
}
func (m Model) waitForChange() tea.Cmd {
ch := m.changes
return func() tea.Msg {
c, ok := <-ch
return changeMsg{change: c, closed: !ok}
}
}
// startFetch fetches the superproject and every initialized submodule in
// the background. It does nothing while a fetch is running.
func (m *Model) startFetch() tea.Cmd {
if m.fetcher == nil || m.fetch.Running || m.repo == nil {
return nil
}
paths := []string{""}
submodule.Walk(m.repo.Submodules, func(s *submodule.Submodule) {
if m.fetcher.Fetchable(s.Path) {
paths = append(paths, s.Path)
}
})
m.fetch = header.Fetch{Running: true, Total: len(paths), At: m.fetch.At}
cmds := []tea.Cmd{m.tick()}
for _, p := range paths {
f, ctx := m.fetcher, m.ctx
cmds = append(cmds, func() tea.Msg { return fetchDoneMsg{p, f.Fetch(ctx, p)} })
}
return tea.Batch(cmds...)
}
// busy reports whether something shows a spinner.
func (m Model) busy() bool {
if m.repo == nil || m.fetch.Running {
return true
}
loading := false
submodule.Walk(m.repo.Submodules, func(s *submodule.Submodule) {
loading = loading || (s.Status == nil && s.Err == nil)
})
return loading
}
// tick schedules a spinner tick if none is scheduled.
func (m *Model) tick() tea.Cmd {
if m.ticking {
return nil
}
m.ticking = true
return m.spinner.Tick
}
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) { switch msg := msg.(type) {
case tea.WindowSizeMsg: case tea.WindowSizeMsg:
@@ -88,25 +177,35 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, nil return m, nil
case treeLoadedMsg: case treeLoadedMsg:
if msg.err != nil { return m.treeLoaded(msg)
m.err = msg.err
return m, nil
}
m.repo = msg.repo
m.byPath = map[string]*submodule.Submodule{}
var cmds []tea.Cmd
submodule.Walk(m.repo.Submodules, func(s *submodule.Submodule) {
m.byPath[s.Path] = s
cmds = append(cmds, m.loadStatus(s.Path))
})
m.loading = len(cmds)
m.tree.SetRoots(m.repo.Submodules)
return m, tea.Batch(cmds...)
case statusLoadedMsg: case statusLoadedMsg:
if s, ok := m.byPath[msg.path]; ok { if s, ok := m.byPath[msg.path]; ok {
s.Status, s.Err = msg.status, msg.err if msg.err != nil {
m.loading-- s.Err = msg.err
} else {
s.Status, s.Err = msg.status, nil
}
}
if m.loads.done(msg.path) {
return m, m.loadStatus(msg.path)
}
return m, nil
case changeMsg:
return m.changed(msg)
case fetchDoneMsg:
m.fetch.Done++
if msg.err != nil {
m.fetch.Failed++
}
if s, ok := m.byPath[msg.path]; ok {
s.FetchErr = msg.err
}
if m.fetch.Done == m.fetch.Total {
m.fetch.Running = false
m.fetch.At = time.Now()
} }
return m, nil return m, nil
@@ -115,8 +214,9 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.spinner, cmd = m.spinner.Update(msg) m.spinner, cmd = m.spinner.Update(msg)
m.tree.SetSpinner(m.spinner.View()) m.tree.SetSpinner(m.spinner.View())
m.detail.SetSpinner(m.spinner.View()) m.detail.SetSpinner(m.spinner.View())
if m.repo != nil && m.loading == 0 { if !m.busy() {
return m, nil // stop ticking once everything is loaded m.ticking = false // stop ticking until there is work again
return m, nil
} }
return m, cmd return m, cmd
@@ -132,6 +232,81 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, nil return m, nil
} }
// treeLoaded installs a (re)loaded tree. On a reload the known statuses are
// carried over by path, so rows don't fall back to a spinner.
func (m Model) treeLoaded(msg treeLoadedMsg) (tea.Model, tea.Cmd) {
var cmds []tea.Cmd
if m.loads.done(treeKey) {
cmds = append(cmds, m.loadTree())
}
initial := m.repo == nil
if msg.err != nil {
if initial {
m.err = msg.err
return m, nil
}
cmds = append(cmds, m.footer.Flash("reload failed: "+msg.err.Error()))
return m, tea.Batch(cmds...)
}
old := m.byPath
m.repo = msg.repo
m.byPath = map[string]*submodule.Submodule{}
reload := initial || m.reloadStatuses
m.reloadStatuses = false
submodule.Walk(m.repo.Submodules, 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
}
if reload || s.Status == nil {
cmds = append(cmds, m.reloadStatus(s.Path))
}
})
if initial {
m.tree.SetRoots(m.repo.Submodules)
if m.watcher != nil {
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)
}
cmds = append(cmds, m.tick())
return m, tea.Batch(cmds...)
}
// changed reacts to a change the watcher saw, whoever made it.
func (m Model) changed(msg changeMsg) (tea.Model, tea.Cmd) {
if msg.closed {
return m, nil
}
cmds := []tea.Cmd{m.waitForChange()}
c := msg.change
switch {
case c.Path == "" || c.Structure:
// 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))
default:
s, ok := m.byPath[c.Path]
if !ok {
break
}
cmds = append(cmds, m.reloadStatus(s.Path))
if c.Local { // the children's pins live in this repository
for _, child := range s.Children {
cmds = append(cmds, m.reloadStatus(child.Path))
}
}
}
return m, tea.Batch(cmds...)
}
func (m Model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { func (m Model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
k := m.keys k := m.keys
switch { switch {
@@ -162,7 +337,23 @@ func (m Model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
m.tree.PageUp() m.tree.PageUp()
case key.Matches(msg, k.PageDown): case key.Matches(msg, k.PageDown):
m.tree.PageDown() m.tree.PageDown()
case key.Matches(msg, k.Update, k.Bump, k.Fetch, k.Filter, k.Issues): case key.Matches(msg, k.Fetch):
var flash string
switch {
case m.fetcher == nil:
flash = "this data source can't fetch"
case m.fetch.Running:
flash = "already fetching"
}
if flash != "" {
cmd := m.footer.Flash(flash)
m.layout()
return m, cmd
}
return m, m.startFetch()
case key.Matches(msg, k.Reload):
return m, m.reloadTree(true)
case key.Matches(msg, k.Update, k.Bump, k.Filter, k.Issues):
cmd := m.footer.Flash("“" + msg.String() + "” is not implemented in the prototype") cmd := m.footer.Flash("“" + msg.String() + "” is not implemented in the prototype")
m.layout() m.layout()
return m, cmd return m, cmd
@@ -204,7 +395,7 @@ func (m Model) View() string {
} }
return zone.Scan(lipgloss.JoinVertical(lipgloss.Left, return zone.Scan(lipgloss.JoinVertical(lipgloss.Left,
header.View(m.theme, m.repo, m.width), header.View(m.theme, m.repo, m.fetch, m.spinner.View(), m.width),
body, body,
m.footer.View(), m.footer.View(),
)) ))
+6
View File
@@ -99,6 +99,12 @@ func (m Model) lines(s *submodule.Submodule) []string {
label("")+muted.Render("via "+st.Pin.Source.String()), label("")+muted.Render("via "+st.Pin.Source.String()),
) )
} }
if st.Presence == submodule.PresenceOK {
out = append(out, label("Fetched")+muted.Render(theme.Ago(st.FetchedAt)))
}
if s.FetchErr != nil {
out = append(out, theme.Fg(t.Warn).Width(labelWidth).Render("fetch")+text.Render("failed: "+s.FetchErr.Error()))
}
// commits // commits
commitLine := func(name string, c *submodule.Commit, highlight bool) string { commitLine := func(name string, c *submodule.Commit, highlight bool) string {
+29 -3
View File
@@ -3,6 +3,7 @@ package header
import ( import (
"fmt" "fmt"
"time"
"github.com/charmbracelet/lipgloss" "github.com/charmbracelet/lipgloss"
@@ -10,8 +11,18 @@ import (
"submarine/internal/tui/theme" "submarine/internal/tui/theme"
) )
// View renders repo info, state counts and fetch age on one line. // Fetch is the progress of the application's own background fetch.
func View(t theme.Theme, repo *submodule.Repo, width int) string { type Fetch struct {
Running bool
Done int // repositories fetched (or failed)
Total int // repositories to fetch
Failed int // failed fetches of the last run
At time.Time // when the last run finished
}
// 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 {
bar := lipgloss.NewStyle().Background(t.Bar) bar := lipgloss.NewStyle().Background(t.Bar)
seg := func(c lipgloss.TerminalColor, s string) string { return bar.Foreground(c).Render(s) } seg := func(c lipgloss.TerminalColor, s string) string { return bar.Foreground(c).Render(s) }
@@ -60,7 +71,22 @@ func View(t theme.Theme, repo *submodule.Repo, width int) string {
mid += sep + seg(t.Muted, fmt.Sprintf("%d loading", loading)) mid += sep + seg(t.Muted, fmt.Sprintf("%d loading", loading))
} }
right := seg(t.Muted, "fetched "+theme.Ago(repo.FetchedAt)+" ") var right string
switch {
case fetch.Running:
right = seg(t.Primary, spinner) + seg(t.Muted, fmt.Sprintf(" fetching %d/%d ", fetch.Done, fetch.Total))
default:
// our own fetch may have finished before the watcher saw FETCH_HEAD
at := repo.FetchedAt
if fetch.At.After(at) {
at = fetch.At
}
right = seg(t.Muted, "fetched "+theme.Ago(at))
if fetch.Failed > 0 {
right += seg(t.Muted, " · ") + seg(t.Warn, fmt.Sprintf("%d failed", fetch.Failed))
}
right += seg(t.Muted, " ")
}
gap := width - lipgloss.Width(left) - lipgloss.Width(mid) - lipgloss.Width(right) gap := width - lipgloss.Width(left) - lipgloss.Width(mid) - lipgloss.Width(right)
if gap < 4 { if gap < 4 {
+7 -6
View File
@@ -3,11 +3,11 @@ package tui
import "github.com/charmbracelet/bubbles/key" import "github.com/charmbracelet/bubbles/key"
type keyMap struct { type keyMap struct {
Up, Down, Left, Right, Toggle key.Binding Up, Down, Left, Right, Toggle key.Binding
CollapseAll, ExpandAll key.Binding CollapseAll, ExpandAll key.Binding
Home, End, PageUp, PageDown key.Binding Home, End, PageUp, PageDown key.Binding
Update, Bump, Fetch, Filter, Issues key.Binding Update, Bump, Fetch, Reload, Filter, Issues key.Binding
Help, Quit key.Binding Help, Quit key.Binding
} }
func defaultKeys() keyMap { func defaultKeys() keyMap {
@@ -26,6 +26,7 @@ func defaultKeys() keyMap {
Update: key.NewBinding(key.WithKeys("u"), key.WithHelp("u", "checkout pin")), Update: key.NewBinding(key.WithKeys("u"), key.WithHelp("u", "checkout pin")),
Bump: key.NewBinding(key.WithKeys("b"), key.WithHelp("b", "bump pin to tip")), Bump: key.NewBinding(key.WithKeys("b"), key.WithHelp("b", "bump pin to tip")),
Fetch: key.NewBinding(key.WithKeys("f"), key.WithHelp("f", "fetch")), 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")), Filter: key.NewBinding(key.WithKeys("/"), key.WithHelp("/", "filter")),
Issues: key.NewBinding(key.WithKeys("!"), key.WithHelp("!", "problems only")), Issues: key.NewBinding(key.WithKeys("!"), key.WithHelp("!", "problems only")),
Help: key.NewBinding(key.WithKeys("?"), key.WithHelp("?", "help")), Help: key.NewBinding(key.WithKeys("?"), key.WithHelp("?", "help")),
@@ -42,7 +43,7 @@ func (k keyMap) FullHelp() [][]key.Binding {
{k.Up, k.Down, k.Left, k.Right, k.Toggle}, {k.Up, k.Down, k.Left, k.Right, k.Toggle},
{k.CollapseAll, k.ExpandAll}, {k.CollapseAll, k.ExpandAll},
{k.Home, k.End, k.PageUp, k.PageDown}, {k.Home, k.End, k.PageUp, k.PageDown},
{k.Update, k.Bump, k.Fetch, k.Filter, k.Issues}, {k.Update, k.Bump, k.Fetch, k.Reload, k.Filter, k.Issues},
{k.Help, k.Quit}, {k.Help, k.Quit},
} }
} }
+14
View File
@@ -12,3 +12,17 @@ type statusLoadedMsg struct {
status *submodule.Status status *submodule.Status
err error err error
} }
// fetchDoneMsg reports one finished fetch. It only updates the fetch
// progress; refreshing is left to the watcher, which also sees fetches made
// outside the application.
type fetchDoneMsg struct {
path string // "" is the superproject
err error
}
// changeMsg is a change reported by the provider's watcher.
type changeMsg struct {
change submodule.Change
closed bool // the watcher stopped
}
+43
View File
@@ -0,0 +1,43 @@
package tui
// treeKey is the reloader key for loading the tree; submodules use their
// path.
const treeKey = "\x00tree"
// reloader allows at most one load per key at a time. A request while a
// load is running marks the key dirty, and the load runs once more after
// the current one finishes, so the last result always reflects the latest
// change and results never arrive out of order.
type reloader struct {
running map[string]bool
dirty map[string]bool
}
func newReloader() reloader {
return reloader{running: map[string]bool{}, dirty: map[string]bool{}}
}
// start reports whether a load for key should start now. If one is already
// running, the key is marked dirty instead.
func (r reloader) start(key string) bool {
if r.running[key] {
r.dirty[key] = true
return false
}
r.running[key] = true
return true
}
// done marks the load for key as finished. It reports whether the load must
// run again because it was requested meanwhile; the key then stays running.
func (r reloader) done(key string) (again bool) {
if r.dirty[key] {
delete(r.dirty, key)
return true
}
delete(r.running, key)
return false
}
// busy reports whether any load is running.
func (r reloader) busy() bool { return len(r.running) > 0 }
+31
View File
@@ -0,0 +1,31 @@
package tui
import "testing"
func TestReloader(t *testing.T) {
r := newReloader()
if !r.start("a") {
t.Fatal("first start: want true")
}
if r.start("a") || r.start("a") {
t.Fatal("start while running: want false")
}
if !r.start("b") {
t.Fatal("other key: want true")
}
if !r.done("a") {
t.Fatal("done after requests while running: want again")
}
if r.done("a") {
t.Fatal("second done: requests were coalesced into one rerun")
}
if r.done("b") {
t.Fatal("done without requests: want no rerun")
}
if r.busy() {
t.Fatal("busy after all done")
}
if !r.start("a") {
t.Fatal("start after done: want true")
}
}
+22
View File
@@ -47,6 +47,28 @@ func (m *Model) SetRoots(roots []*submodule.Submodule) {
} }
} }
// ReplaceRoots swaps in a reloaded tree. Unlike SetRoots it keeps the
// expansion state and the cursor (by path); if the selected submodule is
// gone, the cursor moves to the first one.
func (m *Model) ReplaceRoots(roots []*submodule.Submodule) {
var path string
if m.cursor != nil {
path = m.cursor.Path
}
m.roots = roots
m.cursor = nil
for _, r := range m.rows() {
if r.kind == rowNode && r.node.Path == path {
m.cursor = r.node
break
}
}
if m.cursor == nil && len(roots) > 0 {
m.cursor = m.rows()[m.firstNodeRow()].node
}
m.clampOffset()
}
func (m *Model) SetSize(w, h int) { func (m *Model) SetSize(w, h int) {
m.width, m.height = w, h m.width, m.height = w, h
m.clampOffset() m.clampOffset()
+17
View File
@@ -69,6 +69,23 @@ func visible(m *Model) []string {
return out return out
} }
func TestReplaceRootsKeepsState(t *testing.T) {
m := newTree(t)
m.Down() // b
m.Right() // expand b, cursor on c
m.Right() // expand c, cursor on d
assertCursor(t, m, "d")
m.ReplaceRoots(fixture()) // same paths, new nodes
assertCursor(t, m, "d")
if got := len(visible(m)); got != 8 {
t.Errorf("visible rows = %v, want b and c still expanded", visible(m))
}
m.ReplaceRoots(fixture()[:1]) // only "a" left
assertCursor(t, m, "a")
}
func TestInitiallyCollapsedWithHeaders(t *testing.T) { func TestInitiallyCollapsedWithHeaders(t *testing.T) {
m := newTree(t) m := newTree(t)
got := visible(m) got := visible(m)