4 Commits
Author SHA1 Message Date
simonfrankenandClaude Sonnet 5.5 f9838c0c99 Fix flaky TestWatch when a fetch is reported in two rounds
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-22 10:24:11 +02:00
simonfrankenandClaude Sonnet 5.5 651ab10f51 Use sub as the default command in the README
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-22 10:18:34 +02:00
simonfrankenandClaude Sonnet 5.5 e0f4ac823b Show staged pins in the tree
A staged pin was only visible in the detail pane, so the header's
"n pin staged" could not be traced to a row. The status column now shows
a blue "staged" instead of "in sync", a blue marker after an issue word,
and the marker on collapsed rows with a staged pin below. It is still not an
issue and not counted in +n.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-22 10:14:35 +02:00
simonfrankenandClaude Sonnet 5.5 83824e42c1 Add a README with a screenshot and examples
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-21 21:28:49 +02:00
12 changed files with 195 additions and 18 deletions
+135
View File
@@ -0,0 +1,135 @@
# submarine
**A terminal UI for git repositories with many submodules.**
See what needs your attention. Ignore the rest.
`git submodule status` gives you a wall of commit hashes. submarine answers
the questions you actually have:
- Which branch is this submodule on, and is the pin current?
- Which submodules need me, and which are fine?
- Is anything dangerous, like a pin nobody else can fetch?
![submarine showing a tree with nested submodules and the detail pane of an off-pin submodule that is one commit behind its branch](docs/images/overview.png)
## Why you might like it
- **Only submodules, as a tree.** Nested submodules at any depth, grouped by
directory, collapsed until you open them.
- **Branch plus distance, not hashes.** `origin/main ↓2` says more than
`b7c8d9e`. A pin on a feature branch shows that branch.
- **Problems are loud, healthy is quiet.** Submodules without issues are
muted `in sync`; a staged pin shows as blue `staged` (or a `●` after an
issue word). A collapsed parent counts the problems hidden below it
(`·1`) and shows `●` if a staged pin is hidden.
- **Words, not symbols.** Every state is a short word (`unpushed`, `conflict`,
`missing`, `off pin`, `changed`), so nothing depends on color alone.
- **Live.** Fetches in the background on startup and follows changes you make
in other shells within about two seconds.
- **Built for dozens to hundreds of submodules.** Each status loads
asynchronously, so the UI never freezes.
## Install
```sh
curl -fsSL https://git.simon-franken.de/simonfranken/submarine/raw/branch/main/install.sh | sh
```
Installs `sub` and `submarine` (linux/macOS, amd64/arm64) to `~/.local/bin`.
Both names start the same program; the examples below use the shorter `sub`.
Update later with `sub update`.
## Try it
```sh
sub # the repository in the current directory
sub ~/code/project # any directory inside a repository
sub --no-fetch # skip the background fetch
sub --mock # built-in sample data, no repository needed
```
`submarine` works everywhere `sub` does, e.g. `submarine --mock`.
## Examples
### Find what needs attention
Problems stand out: `unpushed` in red, `off pin` and `changed` in yellow,
everything else muted. Select a submodule to see the details on the right:
every issue in words, the resolved ref and why, the pinned, checked-out and
tip commits, and a preview of uncommitted changes.
```
src/ │ src/
core off pin origin/main │ net
▸ net [2] unpushed origin/main ↑1│
ui in sync origin/main │ unpushed pin not pushed to any remote
│
│ Ref origin/main ↑1
│ via checked out branch
│ Pinned b7c8d9e Hotfix null pointer
│ Checkout b7c8d9e Hotfix null pointer
│ Tip 6f5e4d3 Release 2.1.0
```
### Jump to a submodule with `/`
Press `/` and type. Only matching submodules (and the parents that lead to
them) stay. Your selection moves to the next match if it was filtered out.
```
libs/
▾ log in sync origin/main
modules/
nested changed origin/main
/ nest enter navigate • esc close
```
`enter` keeps the filter and gives you the normal keys back, `esc` removes
it and leaves your selection where it is.
### Copy a path
Select a submodule and press `c`. Its absolute directory lands in your
clipboard, ready for `cd <paste>`. It works over SSH too (OSC 52; in tmux,
`set -g set-clipboard on`).
### Fetch and reload
`f` fetches all repositories again, `r` reloads everything after you edited
files.
## Keys
| Key | Action |
|---|---|
| `↑` `↓` / `j` `k` | Move |
| `←` `→` / `h` `l` | Fold / unfold, parent / first child |
| `H` `L` / `⇧←` `⇧→` | Collapse / expand all |
| `space` | Toggle the selected submodule |
| `home` `end` / `g` `G`, `pgup` `pgdn` | Jump, page |
| `/` | Filter |
| `c` | Copy path |
| `f` / `r` | Fetch / reload |
| `?` | Full help |
| `q` | Quit |
The mouse works too: click to select, click `▸` to toggle, wheel to move.
## Status
A prototype. Browsing is complete; the actions `u` (check out the pin),
`b` (bump the pin to the tip) and `!` (problems only) are planned.
## Documentation
Requirements, state rules, UX guide, architecture and the decision log are in
[docs/](docs/README.md).
## Development
```sh
go run ./cmd/submarine --mock # run on mock data
scripts/make-fixture.sh DIR # build a test superproject with every state
go vet ./... && go test ./...
```
+12
View File
@@ -246,3 +246,15 @@ usual keys. A filtered-out selection moves to the next visible submodule
(previous if none follows) instead of the first, so the cursor stays near (previous if none follows) instead of the first, so the cursor stays near
where it was. This adds the `bubbles/textinput` dependency (and its where it was. This adds the `bubbles/textinput` dependency (and its
`atotto/clipboard` import). `atotto/clipboard` import).
## Staged pin is shown in the tree, but is not an issue
A staged pin was only visible in the detail pane, so the header's `1 pin
staged` could not be traced to a row, and `in sync nothing to do` was
wrong for a submodule whose pin change still has to be committed. The tree
now shows it in the status column: blue `staged` replaces `in sync`; next to
an issue word it is a blue `●` (an issue wins the word, since the cell holds
one word, D3). It is not counted in `+n`, because `+n` and the header count
issues and a staged pin stays quiet (D4). A collapsed row shows `●` if a
staged pin is hidden below it (`Hidden.Staged`), placed before the `·k`
counts so truncation of the 16-column cell cuts the counts first.
Binary file not shown.

After

Width:  |  Height:  |  Size: 127 KiB

+3 -2
View File
@@ -35,7 +35,7 @@ commit hashes. It doesn't answer the questions users actually have:
e.g. `[2]`. e.g. `[2]`.
- **F6** A collapsed submodule shows the number of issues **hidden** in all - **F6** A collapsed submodule shows the number of issues **hidden** in all
its descendants (`·1 ·2`), one count per level: errors in red, then its descendants (`·1 ·2`), one count per level: errors in red, then
warnings in yellow. warnings in yellow. A staged pin below shows as a blue `●` before them.
- **F7** Expansion state of descendants is remembered when a parent is - **F7** Expansion state of descendants is remembered when a parent is
collapsed and restored when it is expanded again (except after collapsed and restored when it is expanded again (except after
"collapse all", which resets it). "collapse all", which resets it).
@@ -44,7 +44,8 @@ commit hashes. It doesn't answer the questions users actually have:
- **F10** Each submodule shows at most **one** state word: the most severe - **F10** Each submodule shows at most **one** state word: the most severe
issue, plus `+n` if there are more. Submodules without issues show a issue, plus `+n` if there are more. Submodules without issues show a
muted `in sync`. muted `in sync`, or a blue `staged` if their pin change is staged. With
issues, a staged pin adds a blue `●` after the word.
See [state-model.md](state-model.md) for the full rules. See [state-model.md](state-model.md) for the full rules.
- **F11** Each submodule shows its pin as a ref (`origin/main`, a tag, or a - **F11** Each submodule shows its pin as a ref (`origin/main`, a tag, or a
short SHA as a fallback) and the distance to that ref (`↑n`, `↓n`, `↑n↓m`). short SHA as a fallback) and the distance to that ref (`↑n`, `↓n`, `↑n↓m`).
+2
View File
@@ -82,6 +82,8 @@ Rules:
changed. changed.
- **Not issues** (quiet): in sync, pin behind/ahead/diverged but pushed, - **Not issues** (quiet): in sync, pin behind/ahead/diverged but pushed,
staged pin, detached HEAD on the pin. They are shown as information only. staged pin, detached HEAD on the pin. They are shown as information only.
A staged pin is also visible in the tree (`staged` or a `●` marker), see
[ux-guide.md](ux-guide.md#tree-row), but is never counted as an issue.
- A loading error is shown as `error` (Danger) in the UI; it is not an issue - A loading error is shown as `error` (Danger) in the UI; it is not an issue
kind. kind.
+4 -4
View File
@@ -63,7 +63,7 @@ indent ▸ name [n] status ref delta
| indent | 2 per depth | | | indent | 2 per depth | |
| toggle | 3 | `▸` collapsed, `▾` expanded, blank for leaves | | toggle | 3 | `▸` collapsed, `▾` expanded, blank for leaves |
| name | fits the widest visible name | name, `[n]` = direct children (collapsed only) | | name | fits the widest visible name | name, `[n]` = direct children (collapsed only) |
| status | 16 | issue word, `+n`, `·k` hidden issues per level (red, then yellow); muted `in sync` without issues; spinner while loading | | status | 16 | issue word, `+n`, `●` staged pin, `·k` hidden issues per level (red, then yellow); blue `staged` or muted `in sync` without issues; spinner while loading |
| ref | widest ref, max 22 | resolved ref; muted short SHA if unresolved | | ref | widest ref, max 22 | resolved ref; muted short SHA if unresolved |
| delta | 6 | `↑n`, `↓n`, `↑n↓m` | | delta | 6 | `↑n`, `↓n`, `↑n↓m` |
@@ -77,8 +77,8 @@ indent ▸ name [n] status ref delta
1. Directory (muted) and name (bold) 1. Directory (muted) and name (bold)
2. One line per issue: word (colored, 10 columns) + explanation; 2. One line per issue: word (colored, 10 columns) + explanation;
`in sync nothing to do` if there are none; `staged` and `in sync nothing to do` if there are none and no pin is staged;
`detached HEAD on pin` as information `staged` and `detached HEAD on pin` as information
3. `Changes` (only with uncommitted changes), see 3. `Changes` (only with uncommitted changes), see
[Change preview](#change-preview) [Change preview](#change-preview)
4. `Ref` with distance and `via <rule>`; `Fetched <age>` of the submodule; 4. `Ref` with distance and `via <rule>`; `Fetched <age>` of the submodule;
@@ -165,7 +165,7 @@ colors in components.
|---|---|---| |---|---|---|
| Danger | ANSI `1` red, bold | `unpushed`, `conflict`, `error`, `↑n` of an unpushed pin | | Danger | ANSI `1` red, bold | `unpushed`, `conflict`, `error`, `↑n` of an unpushed pin |
| Warn | ANSI `3` yellow | `missing`, `off pin`, `changed`, differing checkout SHA | | Warn | ANSI `3` yellow | `missing`, `off pin`, `changed`, differing checkout SHA |
| Info | ANSI `4` blue | `staged` | | Info | ANSI `4` blue | `staged`, the `●` staged marker |
| Primary | ANSI `6` cyan | branch names, commit hashes, spinner | | Primary | ANSI `6` cyan | branch names, commit hashes, spinner |
| Muted | ANSI `8` grey | quiet rows, directory headers, arrows, labels | | Muted | ANSI `8` grey | quiet rows, directory headers, arrows, labels |
| Text | terminal default | normal text | | Text | terminal default | normal text |
+7 -1
View File
@@ -139,7 +139,13 @@ func TestWatch(t *testing.T) {
// a commit in another shell // a commit in another shell
git(t, filepath.Join(super, "libs", "store"), "commit", "-q", "--allow-empty", "-m", "Local") git(t, filepath.Join(super, "libs", "store"), "commit", "-q", "--allow-empty", "-m", "Local")
if c := next(t, ch); c != (submodule.Change{Path: "libs/store", Local: true}) { c := next(t, ch)
if c == (submodule.Change{Path: "src/net"}) {
// git fetch writes FETCH_HEAD and the refs at different moments, so
// with a 20ms poll it can be reported in two rounds
c = next(t, ch)
}
if c != (submodule.Change{Path: "libs/store", Local: true}) {
t.Errorf("after commit: %+v, want local change of libs/store", c) t.Errorf("after commit: %+v, want local change of libs/store", c)
} }
+8 -4
View File
@@ -60,13 +60,14 @@ func (s *Status) Issues() []Issue {
return out return out
} }
// Hidden counts issues below a submodule per level. // Hidden counts issues below a submodule per level, plus staged pins (which
type Hidden struct{ Warn, Danger int } // are not issues).
type Hidden struct{ Warn, Danger, Staged int }
// Total is the number of issues of all levels. // Total is the number of issues of all levels; staged pins are not counted.
func (h Hidden) Total() int { return h.Warn + h.Danger } func (h Hidden) Total() int { return h.Warn + h.Danger }
// HiddenIssues counts the issues of all loaded descendants per level. A // HiddenIssues counts the issues and staged pins of all loaded descendants. A
// descendant whose load failed counts as one danger, since its state is // descendant whose load failed counts as one danger, since its state is
// unknown. // unknown.
func (m *Submodule) HiddenIssues() Hidden { func (m *Submodule) HiddenIssues() Hidden {
@@ -75,6 +76,9 @@ func (m *Submodule) HiddenIssues() Hidden {
if c.Status == nil && c.Err != nil { if c.Status == nil && c.Err != nil {
h.Danger++ h.Danger++
} }
if c.Status != nil && c.Status.PinStaged {
h.Staged++
}
for _, is := range c.Status.Issues() { for _, is := range c.Status.Issues() {
switch is.Level { switch is.Level {
case LevelDanger: case LevelDanger:
+3 -3
View File
@@ -39,15 +39,15 @@ func TestIssues(t *testing.T) {
func TestHiddenIssues(t *testing.T) { func TestHiddenIssues(t *testing.T) {
ok := &Status{Pin: PinInfo{Pushed: true}} ok := &Status{Pin: PinInfo{Pushed: true}}
dirty := &Status{Worktree: WorktreeModified, Pin: PinInfo{Pushed: true}} dirty := &Status{Worktree: WorktreeModified, Pin: PinInfo{Pushed: true}, PinStaged: true}
unpushed := &Status{} unpushed := &Status{}
grandchild := &Submodule{Status: unpushed} grandchild := &Submodule{Status: unpushed}
child := &Submodule{Status: dirty, Children: []*Submodule{grandchild}} child := &Submodule{Status: dirty, Children: []*Submodule{grandchild}}
loading := &Submodule{} loading := &Submodule{}
root := &Submodule{Status: ok, Children: []*Submodule{child, {Status: ok}, loading}} root := &Submodule{Status: ok, Children: []*Submodule{child, {Status: ok}, loading}}
if got := root.HiddenIssues(); got != (Hidden{Warn: 1, Danger: 1}) { if got := root.HiddenIssues(); got != (Hidden{Warn: 1, Danger: 1, Staged: 1}) {
t.Errorf("HiddenIssues() = %+v; want 1 warn, 1 danger", got) t.Errorf("HiddenIssues() = %+v; want 1 warn, 1 danger, 1 staged", got)
} }
} }
+5 -2
View File
@@ -97,11 +97,11 @@ func (m Model) lines(s *submodule.Submodule) []string {
for _, is := range issues { for _, is := range issues {
out = append(out, t.LevelStyle(is.Level, lipgloss.NewStyle()).Width(labelWidth).Render(theme.IssueWord(is.Kind))+text.Render(is.Detail)) out = append(out, t.LevelStyle(is.Level, lipgloss.NewStyle()).Width(labelWidth).Render(theme.IssueWord(is.Kind))+text.Render(is.Detail))
} }
if len(issues) == 0 { if len(issues) == 0 && !st.PinStaged {
out = append(out, label(theme.InSync)+muted.Render("nothing to do")) out = append(out, label(theme.InSync)+muted.Render("nothing to do"))
} }
if st.PinStaged { if st.PinStaged {
out = append(out, theme.Fg(t.Info).Width(labelWidth).Render("staged")+text.Render("pin change staged, not committed")) out = append(out, theme.Fg(t.Info).Width(labelWidth).Render(theme.Staged)+text.Render("pin change staged, not committed"))
} }
if st.Checkout == submodule.CheckoutDetached { if st.Checkout == submodule.CheckoutDetached {
out = append(out, label("")+muted.Render("detached HEAD on pin")) out = append(out, label("")+muted.Render("detached HEAD on pin"))
@@ -170,6 +170,9 @@ func (m Model) lines(s *submodule.Submodule) []string {
if h.Warn > 0 { if h.Warn > 0 {
parts = append(parts, t.LevelStyle(submodule.LevelWarn, lipgloss.NewStyle()).Render(fmt.Sprintf("%d warning(s)", h.Warn))) parts = append(parts, t.LevelStyle(submodule.LevelWarn, lipgloss.NewStyle()).Render(fmt.Sprintf("%d warning(s)", h.Warn)))
} }
if h.Staged > 0 {
parts = append(parts, theme.Fg(t.Info).Render(fmt.Sprintf("%d %s", h.Staged, theme.Staged)))
}
if len(parts) > 0 { if len(parts) > 0 {
children += muted.Render(" · ") + strings.Join(parts, muted.Render(", ")) + muted.Render(" below") children += muted.Render(" · ") + strings.Join(parts, muted.Render(", ")) + muted.Render(" below")
} }
+4
View File
@@ -12,6 +12,10 @@ import (
// InSync is the word for a submodule without issues. // InSync is the word for a submodule without issues.
const InSync = "in sync" const InSync = "in sync"
// Staged is the word for a pin change staged in the superproject. It is not
// an issue: it replaces InSync, or follows an issue word as a marker.
const Staged = "staged"
// IssueWord is the short label for an issue kind shown in the tree. // IssueWord is the short label for an issue kind shown in the tree.
func IssueWord(k submodule.IssueKind) string { func IssueWord(k submodule.IssueKind) string {
switch k { switch k {
+12 -2
View File
@@ -410,7 +410,12 @@ func (m Model) nodeLine(r row, nameColW, refW int) string {
nameCell := seg(textColor, name) + seg(t.Muted, count) + pad(nameW-lipgloss.Width(name+count)) nameCell := seg(textColor, name) + seg(t.Muted, count) + pad(nameW-lipgloss.Width(name+count))
// status: the most severe issue as a word, "+n" for further issues; // status: the most severe issue as a word, "+n" for further issues;
// a muted "in sync" without issues, so an empty cell never looks broken // a muted "in sync" without issues, so an empty cell never looks broken.
// A staged pin is no issue: it replaces "in sync", or follows the issue
// as a blue marker (also for staged pins below a collapsed row).
staged := s.Status != nil && s.Status.PinStaged
h := s.HiddenIssues()
stagedMark := staged && len(issues) > 0
var status string var status string
switch { switch {
case s.Err != nil: case s.Err != nil:
@@ -422,10 +427,15 @@ func (m Model) nodeLine(r row, nameColW, refW int) string {
if len(issues) > 1 { if len(issues) > 1 {
status += seg(t.Muted, fmt.Sprintf(" +%d", len(issues)-1)) status += seg(t.Muted, fmt.Sprintf(" +%d", len(issues)-1))
} }
case staged:
status = seg(t.Info, theme.Staged)
default: default:
status = seg(t.Muted, theme.InSync) status = seg(t.Muted, theme.InSync)
} }
if h := s.HiddenIssues(); collapsed && h.Total() > 0 { if stagedMark || collapsed && h.Staged > 0 {
status += pad(1) + seg(t.Info, t.Glyphs.Staged)
}
if collapsed && h.Total() > 0 {
for _, c := range []struct { for _, c := range []struct {
level submodule.Level level submodule.Level
n int n int