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>
5.6 KiB
State model
Code: internal/submodule/model.go, internal/submodule/status.go.
Three commits per submodule
Every submodule involves three commits. All state is derived from comparing them.
| Commit | Source |
|---|---|
| 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 |
This gives two independent questions:
- Is the checkout on the pin? →
off pinif not. - Where is the pin relative to its ref? →
↑n/↓n/↑n↓m.
Raw status dimensions
submodule.Status stores the raw, independent dimensions. A submodule can be
in several at once.
| Field | Values |
|---|---|
Presence |
PresenceOK, NotInitialized, DirMissing |
Checkout |
CheckoutMatch, CheckoutDiffers, CheckoutDetached (on the pin, detached HEAD; informational) |
Worktree |
WorktreeClean, WorktreeModified, WorktreeUntracked, WorktreeConflict |
PinStaged |
Pin changed in the superproject index but not committed |
Pin |
Ref, Source, Ahead, Behind, Pushed |
Pinned, CheckedOut, Tip |
Commits (SHA, subject, author, time) |
Between |
Commits that make up the pin ↔ tip difference |
Status == nil means "still loading". Submodule.Err is set if loading
failed.
Issues: summarizing the dimensions
The UI never shows raw dimensions in the tree. Status.Issues() summarizes
them into issue kinds, each with a level. This is the single source of
truth for what counts as a problem.
| Issue (word) | Combines | Level |
|---|---|---|
unpushed |
Pin not reachable from any remote ref | Danger |
conflict |
Merge conflict in the worktree | Danger |
missing |
Not initialized; directory missing | Warn |
off pin |
Checkout differs from pin | Warn |
changed |
Uncommitted changes; untracked files | Warn |
Rules:
- If the submodule is missing, that is its only issue (nothing else can be determined).
- Issues are returned most severe first: unpushed, conflict, off pin, changed.
- Not issues (quiet): in sync, pin behind/ahead/diverged but pushed, staged pin, detached HEAD on the pin. They are shown as information only.
- A loading error is shown as
error(Danger) in the UI; it is not an issue kind.
Levels
| Level | Meaning | Presentation |
|---|---|---|
LevelQuiet |
Nothing to do | Whole row muted, no word |
LevelWarn |
Look at it | Yellow word |
LevelDanger |
Act now | Red bold word |
Hidden issues
Submodule.HiddenIssues() counts the issues of all loaded descendants
and returns the most severe level. A collapsed submodule shows this as ·k
in that level's color, so a danger can never hide inside a collapsed subtree.
Resolving a pin to a ref
The pin is displayed as a ref, not a hash. A commit can be on several
branches, on none, or deep in the history, so the git provider resolves it by
this order and records which rule applied in PinInfo.Source:
| # | Rule | RefSource |
|---|---|---|
| 1 | branch = … in .gitmodules |
RefFromGitmodules |
| 2 | Branch currently checked out in the submodule | RefFromCurrentBranch |
| 3 | Remote default branch, if it contains the pin | RefFromDefaultBranch |
| 4 | Any remote branch containing the pin, preferring the closest tip (git branch -r --contains) |
RefFromContains |
| 5 | A tag (git describe) |
RefFromTag |
| 6 | Short SHA | RefFromSHA |
Distances: git rev-list --left-right --count <pin>...<ref> gives
Ahead (left) and Behind (right).
Pushed is true if the pin is reachable from any remote ref. A pin that is
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 parentchanged(D7), but a moved nested pin does.- A missing submodule only gets a
PinnedSHA; 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 guessingunpushed.
Distance notation
| Display | Meaning |
|---|---|
↓n |
The ref has n commits the pin doesn't have; the pin is behind |
↑n |
The pin has n commits the ref doesn't have |
↑n↓m |
Diverged |
The arrows follow the convention of git status, lazygit and shell prompts.