diff --git a/AGENTS.md b/AGENTS.md
index dfd457f..7511ebf 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -1,7 +1,7 @@
# AGENTS.md
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
@@ -20,6 +20,7 @@ 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
go vet ./...
go test ./...
@@ -34,8 +35,9 @@ gofmt -l . # must print nothing
- **Layering.** `internal/submodule` has no UI imports and decides what a
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`. The git provider is
- read-only and must never use the network.
+- **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.
- **Colors** only from `theme.Theme`, ANSI 16 colors for meaning (see
`docs/ux-guide.md#colors`). Render segments on a background with a `base`
style (`LevelStyle(level, base)`, `Delta(pin, base)`).
diff --git a/cmd/submarine/main.go b/cmd/submarine/main.go
index 4b4dfee..19c9cc2 100644
--- a/cmd/submarine/main.go
+++ b/cmd/submarine/main.go
@@ -19,8 +19,9 @@ import (
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] [dir]")
+ fmt.Fprintln(flag.CommandLine.Output(), "usage: submarine [--mock] [--no-fetch] [dir]")
flag.PrintDefaults()
}
flag.Parse()
@@ -55,7 +56,7 @@ func main() {
zone.NewGlobal()
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 {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
diff --git a/docs/README.md b/docs/README.md
index af1c213..b40248d 100644
--- a/docs/README.md
+++ b/docs/README.md
@@ -18,13 +18,13 @@ bare commit hash.
## Status
**Prototype.** The UI is complete for browsing. It reads real repositories
-through a read-only git provider (`internal/submodule/git`):
-`submarine [dir]`. `submarine --mock` shows the built-in mock data
-(`internal/submodule/mock`). Actions (`u`, `b`, `f`, `/`, `!`) only show a
-"not implemented" message.
+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.
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.
-3. Refresh after actions and on demand.
diff --git a/docs/architecture.md b/docs/architecture.md
index 6a88334..7f661eb 100644
--- a/docs/architecture.md
+++ b/docs/architecture.md
@@ -13,15 +13,19 @@ internal/submodule/ domain model; NO UI imports
model.go Repo, Submodule, Status, PinInfo, Commit, enums
status.go Issues(), HiddenIssues(), Walk()
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)
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
+ gitdir.go resolves .git dirs and gitdir: files
internal/submodule/mock/ fixture provider with artificial latency
internal/tui/ Bubble Tea application
app.go root model: layout, message routing, loading
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
tree/ tree component: rows, navigation, mouse, view
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
`Children` linked. `Status` fields are nil.
- `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
…` (see D14).
- 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
.gitmodules`) and recurses into initialized submodules. It records, per
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).
- Superproject: branch from `symbolic-ref`, ahead/behind against
`@{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
@@ -80,7 +112,38 @@ 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. 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
@@ -114,7 +177,10 @@ go test ./...
- `internal/submodule`: issue classification and hidden-issue aggregation.
- `internal/submodule/git`: parser unit tests, and integration tests
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
`TestMain`).
diff --git a/docs/decisions.md b/docs/decisions.md
index ce9746e..9a19b8a 100644
--- a/docs/decisions.md
+++ b/docs/decisions.md
@@ -90,7 +90,7 @@ artificial latency.
**Consequences.** A git backend can be added without UI changes; the UI
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
explicit action (`f`).
@@ -146,3 +146,40 @@ Dirt inside nested submodules is ignored (D7); unstaged moves show as
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
+
+**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.
diff --git a/docs/requirements.md b/docs/requirements.md
index d0b0d0f..c5c1a74 100644
--- a/docs/requirements.md
+++ b/docs/requirements.md
@@ -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
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)
| Key | Action |
|---|---|
| `u` | Check out the pinned commit (`git submodule update`) |
| `b` | Bump the pin to the tip of its ref |
-| `f` | Fetch remotes |
| `/` | Filter by name/path |
| `!` | Show only submodules with issues |
@@ -100,12 +115,13 @@ commit hashes. It doesn't answer the questions users actually have:
```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 works from local
- refs and shows how old they are ("fetched 2m ago"); fetching is explicit.
+- **N1** Startup must not block on the network. The tool shows the local
+ state at once and fetches in the background (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
@@ -116,5 +132,7 @@ submarine --mock # built-in mock data
- **N6** Works in narrow terminals (detail pane hides below 90 columns).
- **N7** The UI is independent of the data source: the mock and the git
provider are interchangeable without UI changes.
-- **N8** The git provider is read-only and never uses the network; it uses
- the `git` command line, so the user's git configuration applies.
+- **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.
diff --git a/docs/state-model.md b/docs/state-model.md
index c72c288..47d62fa 100644
--- a/docs/state-model.md
+++ b/docs/state-model.md
@@ -33,6 +33,11 @@ in several at once.
| `Pinned`, `CheckedOut`, `Tip` | Commits (SHA, subject, author, time) |
| `Between` | Commits that make up the pin ↔ tip difference |
| `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
failed.
diff --git a/docs/ux-guide.md b/docs/ux-guide.md
index 1c98197..a6448b4 100644
--- a/docs/ux-guide.md
+++ b/docs/ux-guide.md
@@ -63,7 +63,8 @@ indent ▸ name [n] status ref delta
`detached HEAD on pin` as information
3. `Changes` (only with uncommitted changes), see
[Change preview](#change-preview)
-4. `Ref` with distance and `via `
+4. `Ref` with distance and `via `; `Fetched ` of the submodule;
+ `fetch failed: ` (yellow word) if the last fetch failed
5. `Pinned`, `Checkout`, `Tip`: SHA + subject; the checkout SHA is yellow if it
differs from the pin
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/`).
### Header
-`path branch ↑↓ · n submodules · … · n pin staged · n loading · fetched `
+`path branch ↑↓ · n submodules · … · n pin staged · n loading · `
Issue counts are ordered by severity (unpushed, conflict, missing, off pin,
-changed), each in its level color. Zero counts are omitted. `` is
-`never` if the superproject has never been fetched; a detached superproject
-shows `(detached)` as its branch.
+changed), each in its level color. Zero counts are omitted. A detached
+superproject shows `(detached)` as its branch.
+
+`` 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
@@ -171,7 +183,9 @@ colors in components.
| `pgup`, `pgdn` | Move by one screen |
| `?` | Toggle full help |
| `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
grouped by directory).
diff --git a/internal/submodule/git/exec.go b/internal/submodule/git/exec.go
index ac33a27..a9b7cdf 100644
--- a/internal/submodule/git/exec.go
+++ b/internal/submodule/git/exec.go
@@ -4,9 +4,9 @@ import (
"bytes"
"context"
"errors"
- "fmt"
"os"
"os/exec"
+ "slices"
"strings"
)
@@ -18,22 +18,40 @@ var gitEnv = append(os.Environ(),
"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
// removed. The error includes git's stderr.
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.Env = gitEnv
+ cmd.Env = slices.Concat(gitEnv, env) // a fresh slice: run is called concurrently
var stdout, stderr bytes.Buffer
cmd.Stdout, cmd.Stderr = &stdout, &stderr
if err := cmd.Run(); err != nil {
if ctx.Err() != nil {
return "", ctx.Err()
}
- msg := strings.TrimSpace(stderr.String())
- if msg == "" {
- msg = err.Error()
- }
- return "", fmt.Errorf("git %s: %s", strings.Join(args, " "), msg)
+ return "", &gitError{Args: args, Stderr: strings.TrimSpace(stderr.String()), Err: err}
}
return strings.TrimRight(stdout.String(), "\n"), nil
}
diff --git a/internal/submodule/git/fetch.go b/internal/submodule/git/fetch.go
new file mode 100644
index 0000000..29b31c4
--- /dev/null
+++ b/internal/submodule/git/fetch.go
@@ -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
+}
diff --git a/internal/submodule/git/fetch_test.go b/internal/submodule/git/fetch_test.go
new file mode 100644
index 0000000..633c42d
--- /dev/null
+++ b/internal/submodule/git/fetch_test.go
@@ -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)
+ }
+}
diff --git a/internal/submodule/git/gitdir.go b/internal/submodule/git/gitdir.go
new file mode 100644
index 0000000..b8849c0
--- /dev/null
+++ b/internal/submodule/git/gitdir.go
@@ -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: ".
+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)
+}
diff --git a/internal/submodule/git/provider.go b/internal/submodule/git/provider.go
index e487973..3d59601 100644
--- a/internal/submodule/git/provider.go
+++ b/internal/submodule/git/provider.go
@@ -14,6 +14,7 @@ import (
"slices"
"strings"
"sync"
+ "time"
"submarine/internal/submodule"
)
@@ -27,8 +28,13 @@ const maxBetween = 50
// Provider reads a superproject and its submodules with git.
type Provider struct {
- root string // absolute superproject root
- sem chan struct{} // bounds concurrent Status calls
+ root string // absolute superproject root
+ 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
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 &Provider{
- root: root,
- sem: make(chan struct{}, runtime.NumCPU()),
+ root: root,
+ sem: make(chan struct{}, runtime.NumCPU()),
+ fetchSem: make(chan struct{}, maxFetches),
+ sshEnv: sshBatchEnv(root),
+ WatchInterval: 2 * time.Second,
}, nil
}
@@ -168,6 +177,12 @@ func status(ctx context.Context, in info) (*submodule.Status, error) {
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
head, err := run(ctx, in.dir, "rev-parse", "HEAD")
if err != nil {
diff --git a/internal/submodule/git/provider_test.go b/internal/submodule/git/provider_test.go
index 003d1fd..340dec2 100644
--- a/internal/submodule/git/provider_test.go
+++ b/internal/submodule/git/provider_test.go
@@ -27,11 +27,8 @@ func TestMain(m *testing.M) {
}
defer os.RemoveAll(tmp)
if _, err := exec.LookPath("git"); err == nil {
- _, file, _, _ := runtime.Caller(0)
- script := filepath.Join(filepath.Dir(file), "..", "..", "..", "scripts", "make-fixture.sh")
- out, err := exec.Command("bash", script, filepath.Join(tmp, "fx")).CombinedOutput()
- if err != nil {
- panic("make-fixture.sh: " + err.Error() + "\n" + string(out))
+ if err := makeFixture(filepath.Join(tmp, "fx")); err != nil {
+ panic(err)
}
fixtureDir = filepath.Join(tmp, "fx", "super")
}
@@ -40,6 +37,16 @@ func TestMain(m *testing.M) {
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) {
t.Helper()
if fixtureDir == "" {
diff --git a/internal/submodule/git/watch.go b/internal/submodule/git/watch.go
new file mode 100644
index 0000000..6a3bd95
--- /dev/null
+++ b/internal/submodule/git/watch.go
@@ -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
+ })
+}
diff --git a/internal/submodule/mock/mock.go b/internal/submodule/mock/mock.go
index a6e1db3..afd8178 100644
--- a/internal/submodule/mock/mock.go
+++ b/internal/submodule/mock/mock.go
@@ -4,9 +4,11 @@ package mock
import (
"context"
+ "errors"
"fmt"
"math/rand/v2"
"path"
+ "sync"
"time"
"submarine/internal/submodule"
@@ -15,7 +17,9 @@ import (
// Provider serves a fixed fixture tree.
type Provider struct {
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 {
@@ -32,6 +36,8 @@ type node struct {
}
func (p *Provider) Tree(ctx context.Context) (*submodule.Repo, error) {
+ p.mu.Lock()
+ defer p.mu.Unlock()
p.statuses = map[string]*submodule.Status{}
repo := &submodule.Repo{
Path: "~/coding/my_project",
@@ -57,6 +63,9 @@ func (p *Provider) build(nodes []node, parent *submodule.Submodule) []*submodule
Parent: parent,
}
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
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) {
- delay := p.MinDelay
- if span := p.MaxDelay - p.MinDelay; span > 0 {
- delay += rand.N(span)
- }
- select {
- case <-time.After(delay):
- case <-ctx.Done():
- return nil, ctx.Err()
+ if err := p.sleep(ctx, p.MinDelay, p.MaxDelay); err != nil {
+ return nil, err
}
+ p.mu.RLock()
s, ok := p.statuses[path]
+ p.mu.RUnlock()
if !ok {
return nil, fmt.Errorf("unknown submodule %q", path)
}
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 -----------------------------------------------------------------
var now = time.Now()
diff --git a/internal/submodule/model.go b/internal/submodule/model.go
index 6508567..b658c90 100644
--- a/internal/submodule/model.go
+++ b/internal/submodule/model.go
@@ -29,6 +29,7 @@ type Submodule struct {
Children []*Submodule
Status *Status // nil while loading
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.
@@ -177,6 +178,7 @@ type Status struct {
Pinned *Commit
CheckedOut *Commit
Tip *Commit
- Between []Commit // commits that make up the pin <-> tip difference
- Changes *Changes // nil if the worktree is clean or the details are unknown
+ Between []Commit // commits that make up the pin <-> tip difference
+ Changes *Changes // nil if the worktree is clean or the details are unknown
+ FetchedAt time.Time // last fetch of the submodule; zero if never
}
diff --git a/internal/submodule/provider.go b/internal/submodule/provider.go
index 0496149..988f1c9 100644
--- a/internal/submodule/provider.go
+++ b/internal/submodule/provider.go
@@ -8,3 +8,27 @@ type Provider interface {
Tree(ctx context.Context) (*Repo, 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
+}
diff --git a/internal/tui/app.go b/internal/tui/app.go
index 473631d..77be594 100644
--- a/internal/tui/app.go
+++ b/internal/tui/app.go
@@ -4,6 +4,7 @@ package tui
import (
"context"
+ "time"
"github.com/charmbracelet/bubbles/key"
"github.com/charmbracelet/bubbles/spinner"
@@ -26,16 +27,29 @@ 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
- repo *submodule.Repo
- byPath map[string]*submodule.Submodule
- loading int
- err error
+ repo *submodule.Repo
+ byPath map[string]*submodule.Submodule
+ 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
detail detail.Model
@@ -46,23 +60,30 @@ type Model struct {
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()
k := defaultKeys()
sp := spinner.New(spinner.WithSpinner(spinner.MiniDot))
- return Model{
+ m := Model{
ctx: ctx,
provider: p,
+ opts: opts,
theme: t,
keys: k,
+ loads: newReloader(),
+ ticking: true, // Init schedules the first tick
tree: tree.New(t),
detail: detail.New(t),
footer: footer.New(t, k),
spinner: sp,
}
+ m.fetcher, _ = p.(submodule.Fetcher)
+ m.watcher, _ = p.(submodule.Watcher)
+ return m
}
func (m Model) Init() tea.Cmd {
+ m.loads.start(treeKey)
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) {
switch msg := msg.(type) {
case tea.WindowSizeMsg:
@@ -88,25 +177,35 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, nil
case treeLoadedMsg:
- if msg.err != nil {
- 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...)
+ return m.treeLoaded(msg)
case statusLoadedMsg:
if s, ok := m.byPath[msg.path]; ok {
- s.Status, s.Err = msg.status, msg.err
- m.loading--
+ if msg.err != nil {
+ 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
@@ -115,8 +214,9 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.spinner, cmd = m.spinner.Update(msg)
m.tree.SetSpinner(m.spinner.View())
m.detail.SetSpinner(m.spinner.View())
- if m.repo != nil && m.loading == 0 {
- return m, nil // stop ticking once everything is loaded
+ if !m.busy() {
+ m.ticking = false // stop ticking until there is work again
+ return m, nil
}
return m, cmd
@@ -132,6 +232,81 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
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) {
k := m.keys
switch {
@@ -162,7 +337,23 @@ func (m Model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
m.tree.PageUp()
case key.Matches(msg, k.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")
m.layout()
return m, cmd
@@ -204,7 +395,7 @@ func (m Model) View() string {
}
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,
m.footer.View(),
))
diff --git a/internal/tui/detail/detail.go b/internal/tui/detail/detail.go
index 5e08c68..ae44621 100644
--- a/internal/tui/detail/detail.go
+++ b/internal/tui/detail/detail.go
@@ -99,6 +99,12 @@ func (m Model) lines(s *submodule.Submodule) []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
commitLine := func(name string, c *submodule.Commit, highlight bool) string {
diff --git a/internal/tui/header/header.go b/internal/tui/header/header.go
index ee112b2..21f5e58 100644
--- a/internal/tui/header/header.go
+++ b/internal/tui/header/header.go
@@ -3,6 +3,7 @@ package header
import (
"fmt"
+ "time"
"github.com/charmbracelet/lipgloss"
@@ -10,8 +11,18 @@ import (
"submarine/internal/tui/theme"
)
-// View renders repo info, state counts and fetch age on one line.
-func View(t theme.Theme, repo *submodule.Repo, width int) string {
+// Fetch is the progress of the application's own background fetch.
+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)
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))
}
- 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)
if gap < 4 {
diff --git a/internal/tui/keys.go b/internal/tui/keys.go
index 4fed449..3d0382f 100644
--- a/internal/tui/keys.go
+++ b/internal/tui/keys.go
@@ -3,11 +3,11 @@ package tui
import "github.com/charmbracelet/bubbles/key"
type keyMap struct {
- Up, Down, Left, Right, Toggle key.Binding
- CollapseAll, ExpandAll key.Binding
- Home, End, PageUp, PageDown key.Binding
- Update, Bump, Fetch, Filter, Issues key.Binding
- Help, Quit key.Binding
+ 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
+ Help, Quit key.Binding
}
func defaultKeys() keyMap {
@@ -26,6 +26,7 @@ func defaultKeys() keyMap {
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")),
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.CollapseAll, k.ExpandAll},
{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},
}
}
diff --git a/internal/tui/msgs.go b/internal/tui/msgs.go
index 0c2de2c..cc2a3f0 100644
--- a/internal/tui/msgs.go
+++ b/internal/tui/msgs.go
@@ -12,3 +12,17 @@ type statusLoadedMsg struct {
status *submodule.Status
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
+}
diff --git a/internal/tui/refresh.go b/internal/tui/refresh.go
new file mode 100644
index 0000000..60c2190
--- /dev/null
+++ b/internal/tui/refresh.go
@@ -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 }
diff --git a/internal/tui/refresh_test.go b/internal/tui/refresh_test.go
new file mode 100644
index 0000000..3e113cf
--- /dev/null
+++ b/internal/tui/refresh_test.go
@@ -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")
+ }
+}
diff --git a/internal/tui/tree/tree.go b/internal/tui/tree/tree.go
index 05b6023..92b2ded 100644
--- a/internal/tui/tree/tree.go
+++ b/internal/tui/tree/tree.go
@@ -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) {
m.width, m.height = w, h
m.clampOffset()
diff --git a/internal/tui/tree/tree_test.go b/internal/tui/tree/tree_test.go
index 36cdd6c..7ce9010 100644
--- a/internal/tui/tree/tree_test.go
+++ b/internal/tui/tree/tree_test.go
@@ -69,6 +69,23 @@ func visible(m *Model) []string {
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) {
m := newTree(t)
got := visible(m)