Add read-only git provider

Implement submodule.Provider with the git CLI so submarine reads real
repositories: `submarine [dir]`, with `--mock` for the built-in data.

- Tree reads .gitmodules recursively; Status derives presence, checkout,
  worktree, staged pin, pushed state and pin -> ref resolution from local
  git commands only (no network).
- Concurrent Status calls are bounded by runtime.NumCPU().
- scripts/make-fixture.sh builds a superproject covering every state; the
  integration tests run against it.
- theme.Ago shows "never" for a zero time.
- Docs updated; decisions D14-D16 added.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Simon Franken
2026-10-21 15:32:21 +02:00
co-authored by Claude Opus 5.5
parent c84f34ee2b
commit 64fe34b5b0
15 changed files with 1076 additions and 20 deletions
+8 -7
View File
@@ -17,13 +17,14 @@ bare commit hash.
## Status
**Prototype.** The UI is complete for browsing and runs on mock data
(`internal/submodule/mock`). There is no git backend yet, and actions
(`u`, `b`, `f`, `/`, `!`) only show a "not implemented" message.
**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.
Next steps, roughly in order:
1. Git provider implementing `submodule.Provider` (read-only).
2. Actions: fetch, checkout pin (`submodule update`), bump pin to tip, init.
3. Filter (`/`) and "problems only" (`!`) views.
4. Refresh after actions and on demand.
1. Actions: fetch, checkout pin (`submodule update`), bump pin to tip, init.
2. Filter (`/`) and "problems only" (`!`) views.
3. Refresh after actions and on demand.
+27 -2
View File
@@ -8,11 +8,15 @@ Go, [Bubble Tea](https://github.com/charmbracelet/bubbletea) (v1),
## Package layout
```
cmd/submarine/main.go wiring: choose provider (--mock), start the program
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/ read-only provider using the git CLI
exec.go git runner (environment, errors)
parse.go pure parsers for git output
provider.go Tree, Status, pin → ref resolution
internal/submodule/mock/ fixture provider with artificial latency
internal/tui/ Bubble Tea application
app.go root model: layout, message routing, loading
@@ -52,6 +56,23 @@ type Provider interface {
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`. No command touches 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](state-model.md#git-commands)). 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`.
## Loading flow
1. `Init` runs `Tree()` → `treeLoadedMsg`.
@@ -91,14 +112,18 @@ 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).
- `internal/tui/tree`: navigation rules. Tests need `zone.NewGlobal()` (see
`TestMain`).
Manual check in a headless terminal:
```sh
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
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
+29
View File
@@ -99,3 +99,32 @@ explicit action (`f`).
**Decision.** Keep mouse support (click to select/toggle, wheel to move)
using bubblezone markers per row.
## D14 — Git provider shells out to the git CLI
**Context.** The provider can use a Go library (go-git) or the `git` binary.
**Decision.** Run `git -C <dir> …` with a fixed environment
(`GIT_TERMINAL_PROMPT=0`, `GIT_OPTIONAL_LOCKS=0`, `LC_ALL=C`) and parse
stable output formats (`--porcelain=v2`, custom `--format`).
**Consequences.** Behaves exactly like the user's git (config, `gitdir:`
files, worktrees, partial clones), no new dependency, fast on large repos.
Requires `git` on `PATH`; one process per query.
## D15 — Git provider semantics
**Decision.** The pin is the **index** gitlink, because that is what the
next commit records and what `submodule update` checks out; a difference to
HEAD is `PinStaged`. Worktree state uses `--ignore-submodules=dirty`, so dirt
in a nested submodule doesn't mark its parent (consistent with D7). A pin
whose commit isn't present locally is a loading `error`, not `unpushed`,
because without the commit nothing can be said about it.
`--mock` is no longer the default; `submarine [dir]` reads a real repository.
## D16 — Bounded concurrency inside the provider
**Context.** The app starts one `Status` command per submodule at once
(D11); with hundreds of submodules that would spawn thousands of git
processes.
**Decision.** The git provider limits concurrent `Status` calls to
`runtime.NumCPU()` with a semaphore that respects `ctx`.
**Consequences.** The UI stays unchanged; rows fill in steadily.
+11 -2
View File
@@ -88,6 +88,13 @@ commit hashes. It doesn't answer the questions users actually have:
| `/` | Filter by name/path |
| `!` | Show only submodules with issues |
## Usage
```sh
submarine [dir] # the repository containing dir (default: current directory)
submarine --mock # built-in mock data
```
## Non-functional requirements
- **N1** Startup must not block on the network. The tool works from local
@@ -100,5 +107,7 @@ commit hashes. It doesn't answer the questions users actually have:
- **N5** Works without a Nerd Font. `SUBMARINE_ASCII=1` replaces the few
remaining Unicode symbols with ASCII.
- **N6** Works in narrow terminals (detail pane hides below 90 columns).
- **N7** The UI is independent of the data source: replacing the mock
provider with a git provider requires no UI changes.
- **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.
+33 -1
View File
@@ -9,7 +9,7 @@ them.
| Commit | Source |
|---|---|
| **Pinned** | The gitlink recorded in the superproject (HEAD or index) |
| **Pinned** | The gitlink recorded in the superproject index (differs from HEAD → `PinStaged`) |
| **Checked out** | The submodule's `HEAD` |
| **Tip** | The tip of the ref the pin was resolved to, e.g. `origin/main` |
@@ -97,6 +97,38 @@ Distances: `git rev-list --left-right --count <pin>...<ref>` gives
ahead of its ref can still be pushed (e.g. on another branch); only
`Pushed == false` is dangerous.
## Git commands
How the git provider (`internal/submodule/git`) fills `Status`. All commands
are local; `<pin>` is the index gitlink.
| Field | Command (in the parent repo / in the submodule) |
|---|---|
| `Pinned`, `PinStaged` | parent: `ls-files -s -- <path>` vs `ls-tree HEAD -- <path>` |
| `Presence` | directory missing → `DirMissing`; no `.git` in it → `NotInitialized` |
| `Checkout` | `rev-parse HEAD`, `symbolic-ref -q HEAD` (no branch → detached) |
| `Worktree` | `status --porcelain=v2 --ignore-submodules=dirty` |
| `Pushed` | `for-each-ref --contains <pin> refs/remotes` is not empty |
| `Pin.Ref` | rules above; the remote is `origin`, else the first remote |
| `Ahead`, `Behind` | `rev-list --left-right --count <pin>...<ref>` |
| `Between` | `log <pin>...<ref>`, at most 50 commits |
Details:
- Rule 1: `branch = .` means the superproject's current branch. The rule
applies only if `<remote>/<branch>` exists locally.
- Rule 2 uses the checked-out branch's upstream (`<branch>@{upstream}`).
- Rule 4 compares at most 16 containing branches (oldest tip first) and
picks the one with the fewest commits after the pin.
- Rule 6 shows the short SHA; there is no tip and no distance.
- `--ignore-submodules=dirty`: changes inside a nested submodule do not make
its parent `changed` (D7), but a moved nested pin does.
- A missing submodule only gets a `Pinned` SHA; nested submodules of an
uninitialized submodule are unknown and not shown.
- If the pinned commit doesn't exist in the submodule (not fetched), loading
fails with "pinned commit … not fetched" (shown as `error`) instead of
guessing `unpushed`.
## Distance notation
| Display | Meaning |
+3 -1
View File
@@ -72,7 +72,9 @@ indent ▸ name [n] status ref delta
`path branch ↑↓ · n submodules · <count> <issue>… · n pin staged · n loading · fetched <age>`
Issue counts are ordered by severity (unpushed, conflict, missing, off pin,
changed), each in its level color. Zero counts are omitted.
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.
## Colors