- 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>
8.7 KiB
Architecture
Go, Bubble Tea (v1), Lip Gloss, Bubbles (key, help, spinner), bubblezone (mouse hit areas).
Package layout
cmd/submarine/main.go wiring: choose provider (git or --mock), start the program
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/ 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 tree/status/fetch/change messages
refresh.go reloader: one load per key, coalesced reruns
theme/ colors, glyphs, issue words, change letters, level styles
tree/ tree component: rows, navigation, mouse, view
detail/ detail pane; changes.go: change preview
header/ header line
footer/ help and flash messages
Layering rules
internal/submoduleknows nothing about the UI. Everything that decides what a state means (issue kinds, levels, hidden issues) lives here.internal/tui/themedecides how it looks: words, colors, symbols.- Components (
tree,detail,header,footer) use the theme; they never define colors or classify states themselves. - The UI only accesses data through
submodule.Provider.
Provider boundary
type Provider interface {
Tree(ctx context.Context) (*Repo, error) // structure only, fast
Status(ctx context.Context, path string) (*Status, error) // per submodule, may be slow
}
Optional capabilities; the app type-asserts them:
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}
}
Treereturns the superproject and all submodules withParentandChildrenlinked.Statusfields are nil.Statusis called once per submodule, concurrently. It must be safe to call concurrently and must respectctx.Submodule.Path(relative to the superproject root) is the unique ID.
The mock provider (internal/submodule/mock) covers every state and sleeps
100–800 ms per Status call to exercise the loading path.
Git provider
internal/submodule/git shells out to git -C <dir> … (see D14).
-
Environment:
GIT_TERMINAL_PROMPT=0,GIT_OPTIONAL_LOCKS=0(reads never takeindex.lock),LC_ALL=C. OnlyFetchuses the network. -
Treereads each repository's.gitmodules(git config -f .gitmodules) and recurses into initialized submodules. It records, per path, the parent repository and the.gitmodulesbranch forStatus.Nameis the last element ofRelPath. -
Statusruns about ten small git commands per submodule (see state-model.md). At mostruntime.NumCPU()Statuscalls run at once; the others wait on a semaphore and still respectctx(D16). -
Superproject: branch from
symbolic-ref, ahead/behind against@{upstream}, "fetched" from the mtime ofFETCH_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 configuredGIT_SSH_COMMANDorcore.sshCommand. The error is git's first line of output. -
Watchpolls 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.gitexists
Git dirs are resolved from
.git(directory, orgitdir:file for submodules, pluscommondirfor 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 (rreloads). - remote part:
Loading flow
InitrunsTree()→treeLoadedMsg.- The app indexes submodules by path and starts one
tea.Cmdper submodule callingStatus()→statusLoadedMsg. - Each result is stored on the submodule (
StatusorErr); the view updates incrementally, including parents' hidden-issue counts. - After the first tree: the watcher starts (
changeMsg), and the background fetch starts onetea.Cmdper fetchable repository (fetchDoneMsg). - 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
fetchDoneMsgonly updates the progress andSubmodule.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
.gitmodulesanywhere → reload the tree; if local, also every status
- submodule, remote only → reload its
- A reloaded tree carries the old
Status,ErrandFetchErrover by path, andtree.ReplaceRootskeeps expansion and the cursor, so a reload doesn't flash spinners or move the selection. - The
reloaderruns 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
- State: roots,
expanded map[path]bool, cursor (a*Submodule), scroll offset, size. rows()flattens the visible tree into directory-header rows and submodule rows. Directory grouping usespath.Dir(RelPath)and keeps the order of first appearance.- Navigation methods (
Up,Down,Left,Right,Toggle,CollapseAll,ExpandAll,Home,End,PageUp,PageDown) are plain methods without Bubble Tea dependencies, so they are unit-tested directly. - Key mapping happens in
app.go; the tree only handles mouse messages. - Mouse zones:
toggle:<path>androw:<path>, prefixed per instance.
Rendering notes
- Each segment of a row is rendered with the row's
basestyle (background), because an inner style's reset would otherwise clear the outer background. - Widths are measured with
lipgloss.Width; truncation usesansi.Truncate, which keeps escape sequences and bubblezone markers intact. zone.Scanruns once, on the root view.
Testing
go vet ./...
go test ./...
internal/submodule: issue classification and hidden-issue aggregation.internal/submodule/git: parser unit tests, and integration tests against a fixture repository built byscripts/make-fixture.sh(skipped ifgitis 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 needzone.NewGlobal()(seeTestMain).
Manual check in a headless terminal:
scripts/make-fixture.sh /tmp/subfix # superproject with every state
go build -o /tmp/submarine ./cmd/submarine
tmux new-session -d -s sub -x 130 -y 24 "/tmp/submarine /tmp/subfix/super"
tmux send-keys -t sub Down Right
tmux capture-pane -pt sub # add -e to see colors
tmux kill-session -t sub