This repository has been archived on 2026-10-25. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
submarine/docs/architecture.md
T
Simon FrankenandClaude Opus 5.5 27f0c0b5d2 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>
2026-10-21 16:15:22 +02:00

8.7 KiB
Raw Blame History

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/submodule knows nothing about the UI. Everything that decides what a state means (issue kinds, levels, hidden issues) lives here.
  • internal/tui/theme decides 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}
}
  • 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 call concurrently and must respect ctx.
  • 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 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. Name is the last element of RelPath.

  • Status runs about ten small git commands per submodule (see state-model.md). At most runtime.NumCPU() Status calls run at once; the others wait on a semaphore and still 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

  1. Init runs Tree() → treeLoadedMsg.
  2. The app indexes submodules by path and starts one tea.Cmd per submodule calling Status() → statusLoadedMsg.
  3. Each result is stored on the submodule (Status or Err); the view updates incrementally, including parents' hidden-issue counts.
  4. After the first tree: the watcher starts (changeMsg), and the background fetch starts one tea.Cmd per fetchable repository (fetchDoneMsg).
  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

  • 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 uses path.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> and row:<path>, prefixed per instance.

Rendering notes

  • Each segment of a row is rendered with the row's base style (background), because an inner style's reset would otherwise clear the outer background.
  • Widths are measured with lipgloss.Width; truncation uses ansi.Truncate, which keeps escape sequences and bubblezone markers intact.
  • zone.Scan runs 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 by scripts/make-fixture.sh (skipped 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).

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