Archived
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:
co-authored by
Claude Opus 5.5
parent
19c6f17eb2
commit
27f0c0b5d2
+6
-6
@@ -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.
|
||||
|
||||
+71
-5
@@ -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 <dir> …` (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`).
|
||||
|
||||
|
||||
+38
-1
@@ -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.
|
||||
|
||||
+23
-5
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
+20
-6
@@ -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 <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
|
||||
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 · <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,
|
||||
changed), each in its level color. Zero counts are omitted. `<age>` 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.
|
||||
|
||||
`<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
|
||||
|
||||
@@ -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).
|
||||
|
||||
Reference in New Issue
Block a user