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
+6 -3
View File
@@ -1,7 +1,7 @@
# AGENTS.md # AGENTS.md
submarine: a Go + Bubble Tea TUI for repositories with many git submodules. submarine: a Go + Bubble Tea TUI for repositories with many git submodules.
Currently a prototype on mock data. Currently a prototype with a read-only git provider and mock data.
## Read first ## Read first
@@ -18,7 +18,9 @@ and decisions. Read the relevant document before changing behavior:
## Commands ## Commands
```sh ```sh
go run ./cmd/submarine # run (mock data) go run ./cmd/submarine [dir] # run on a repository (default: cwd)
go run ./cmd/submarine --mock # run on mock data
scripts/make-fixture.sh DIR # build a test superproject in DIR
go vet ./... go vet ./...
go test ./... go test ./...
gofmt -l . # must print nothing gofmt -l . # must print nothing
@@ -32,7 +34,8 @@ gofmt -l . # must print nothing
- **Layering.** `internal/submodule` has no UI imports and decides what a - **Layering.** `internal/submodule` has no UI imports and decides what a
state means (`Status.Issues()`). `internal/tui/theme` decides how it looks. state means (`Status.Issues()`). `internal/tui/theme` decides how it looks.
Components never classify states or define colors themselves. Components never classify states or define colors themselves.
- **Data access** only through `submodule.Provider`. - **Data access** only through `submodule.Provider`. The git provider is
read-only and must never use the network.
- **Colors** only from `theme.Theme`, ANSI 16 colors for meaning (see - **Colors** only from `theme.Theme`, ANSI 16 colors for meaning (see
`docs/ux-guide.md#colors`). Render segments on a background with a `base` `docs/ux-guide.md#colors`). Render segments on a background with a `base`
style (`LevelStyle(level, base)`, `Delta(pin, base)`). style (`LevelStyle(level, base)`, `Delta(pin, base)`).
+20 -3
View File
@@ -12,20 +12,37 @@ import (
zone "github.com/lrstanley/bubblezone" zone "github.com/lrstanley/bubblezone"
"submarine/internal/submodule" "submarine/internal/submodule"
"submarine/internal/submodule/git"
"submarine/internal/submodule/mock" "submarine/internal/submodule/mock"
"submarine/internal/tui" "submarine/internal/tui"
) )
func main() { func main() {
useMock := flag.Bool("mock", true, "use built-in mock data instead of a real repository") useMock := flag.Bool("mock", false, "use built-in mock data instead of a real repository")
flag.Usage = func() {
fmt.Fprintln(flag.CommandLine.Output(), "usage: submarine [--mock] [dir]")
flag.PrintDefaults()
}
flag.Parse() flag.Parse()
if flag.NArg() > 1 {
flag.Usage()
os.Exit(2)
}
var provider submodule.Provider var provider submodule.Provider
if *useMock { if *useMock {
provider = mock.New() provider = mock.New()
} else { } else {
fmt.Fprintln(os.Stderr, "only --mock is supported in the prototype") dir := "."
os.Exit(2) if flag.NArg() == 1 {
dir = flag.Arg(0)
}
p, err := git.New(dir)
if err != nil {
fmt.Fprintln(os.Stderr, "submarine:", err)
os.Exit(2)
}
provider = p
} }
ctx, cancel := context.WithCancel(context.Background()) ctx, cancel := context.WithCancel(context.Background())
+8 -7
View File
@@ -17,13 +17,14 @@ bare commit hash.
## Status ## Status
**Prototype.** The UI is complete for browsing and runs on mock data **Prototype.** The UI is complete for browsing. It reads real repositories
(`internal/submodule/mock`). There is no git backend yet, and actions through a read-only git provider (`internal/submodule/git`):
(`u`, `b`, `f`, `/`, `!`) only show a "not implemented" message. `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: Next steps, roughly in order:
1. Git provider implementing `submodule.Provider` (read-only). 1. Actions: fetch, checkout pin (`submodule update`), bump pin to tip, init.
2. Actions: fetch, checkout pin (`submodule update`), bump pin to tip, init. 2. Filter (`/`) and "problems only" (`!`) views.
3. Filter (`/`) and "problems only" (`!`) views. 3. Refresh after actions and on demand.
4. 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 ## 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 internal/submodule/ domain model; NO UI imports
model.go Repo, Submodule, Status, PinInfo, Commit, enums model.go Repo, Submodule, Status, PinInfo, Commit, enums
status.go Issues(), HiddenIssues(), Walk() status.go Issues(), HiddenIssues(), Walk()
provider.go Provider interface 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/submodule/mock/ fixture provider with artificial latency
internal/tui/ Bubble Tea application internal/tui/ Bubble Tea application
app.go root model: layout, message routing, loading 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 The mock provider (`internal/submodule/mock`) covers every state and sleeps
100–800 ms per `Status` call to exercise the loading path. 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 ## Loading flow
1. `Init` runs `Tree()` → `treeLoadedMsg`. 1. `Init` runs `Tree()` → `treeLoadedMsg`.
@@ -91,14 +112,18 @@ go test ./...
``` ```
- `internal/submodule`: issue classification and hidden-issue aggregation. - `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 - `internal/tui/tree`: navigation rules. Tests need `zone.NewGlobal()` (see
`TestMain`). `TestMain`).
Manual check in a headless terminal: Manual check in a headless terminal:
```sh ```sh
scripts/make-fixture.sh /tmp/subfix # superproject with every state
go build -o /tmp/submarine ./cmd/submarine 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 send-keys -t sub Down Right
tmux capture-pane -pt sub # add -e to see colors tmux capture-pane -pt sub # add -e to see colors
tmux kill-session -t sub 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) **Decision.** Keep mouse support (click to select/toggle, wheel to move)
using bubblezone markers per row. 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 | | `/` | Filter by name/path |
| `!` | Show only submodules with issues | | `!` | 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 ## Non-functional requirements
- **N1** Startup must not block on the network. The tool works from local - **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 - **N5** Works without a Nerd Font. `SUBMARINE_ASCII=1` replaces the few
remaining Unicode symbols with ASCII. remaining Unicode symbols with ASCII.
- **N6** Works in narrow terminals (detail pane hides below 90 columns). - **N6** Works in narrow terminals (detail pane hides below 90 columns).
- **N7** The UI is independent of the data source: replacing the mock - **N7** The UI is independent of the data source: the mock and the git
provider with a git provider requires no UI changes. 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 | | 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` | | **Checked out** | The submodule's `HEAD` |
| **Tip** | The tip of the ref the pin was resolved to, e.g. `origin/main` | | **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 ahead of its ref can still be pushed (e.g. on another branch); only
`Pushed == false` is dangerous. `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 ## Distance notation
| Display | Meaning | | 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>` `path branch ↑↓ · n submodules · <count> <issue>… · n pin staged · n loading · fetched <age>`
Issue counts are ordered by severity (unpushed, conflict, missing, off pin, 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 ## Colors
+52
View File
@@ -0,0 +1,52 @@
package git
import (
"bytes"
"context"
"errors"
"fmt"
"os"
"os/exec"
"strings"
)
// gitEnv keeps git non-interactive, lock-free for reads and its output
// parseable.
var gitEnv = append(os.Environ(),
"GIT_TERMINAL_PROMPT=0",
"GIT_OPTIONAL_LOCKS=0",
"LC_ALL=C",
)
// run executes git in dir and returns stdout with the trailing newline
// removed. The error includes git's stderr.
func run(ctx context.Context, dir string, args ...string) (string, error) {
cmd := exec.CommandContext(ctx, "git", append([]string{"-C", dir}, args...)...)
cmd.Env = gitEnv
var stdout, stderr bytes.Buffer
cmd.Stdout, cmd.Stderr = &stdout, &stderr
if err := cmd.Run(); err != nil {
if ctx.Err() != nil {
return "", ctx.Err()
}
msg := strings.TrimSpace(stderr.String())
if msg == "" {
msg = err.Error()
}
return "", fmt.Errorf("git %s: %s", strings.Join(args, " "), msg)
}
return strings.TrimRight(stdout.String(), "\n"), nil
}
// try is run for commands whose failure only means "no answer", e.g.
// "no upstream" or "ref does not exist". Context errors still propagate.
func try(ctx context.Context, dir string, args ...string) (string, bool, error) {
out, err := run(ctx, dir, args...)
if err != nil {
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
return "", false, err
}
return "", false, nil
}
return out, true, nil
}
+125
View File
@@ -0,0 +1,125 @@
package git
import (
"fmt"
"strconv"
"strings"
"time"
"submarine/internal/submodule"
)
// gitmodule is one entry of a .gitmodules file.
type gitmodule struct {
name, path, url, branch string
}
// parseGitmodules parses the output of
// `git config -f .gitmodules --get-regexp '^submodule\..*\.(path|url|branch)$'`.
// Entries keep the order of first appearance; entries without a path are
// dropped.
func parseGitmodules(out string) []gitmodule {
var order []string
byName := map[string]*gitmodule{}
for _, line := range strings.Split(out, "\n") {
key, value, _ := strings.Cut(line, " ")
key, ok := strings.CutPrefix(key, "submodule.")
if !ok {
continue
}
dot := strings.LastIndexByte(key, '.')
if dot < 0 {
continue
}
name, attr := key[:dot], key[dot+1:]
g := byName[name]
if g == nil {
g = &gitmodule{name: name}
byName[name] = g
order = append(order, name)
}
switch attr {
case "path":
g.path = value
case "url":
g.url = value
case "branch":
g.branch = value
}
}
var mods []gitmodule
for _, name := range order {
if g := byName[name]; g.path != "" {
mods = append(mods, *g)
}
}
return mods
}
// parseWorktree summarizes `git status --porcelain=v2` output. A conflict
// outranks modifications, which outrank untracked files.
func parseWorktree(out string) submodule.Worktree {
w := submodule.WorktreeClean
for _, line := range strings.Split(out, "\n") {
switch {
case strings.HasPrefix(line, "u "):
return submodule.WorktreeConflict
case strings.HasPrefix(line, "1 "), strings.HasPrefix(line, "2 "):
w = submodule.WorktreeModified
case strings.HasPrefix(line, "? ") && w == submodule.WorktreeClean:
w = submodule.WorktreeUntracked
}
}
return w
}
// parseLeftRight parses `git rev-list --left-right --count a...b`.
func parseLeftRight(out string) (left, right int, err error) {
f := strings.Fields(out)
if len(f) != 2 {
return 0, 0, fmt.Errorf("unexpected rev-list output %q", out)
}
if left, err = strconv.Atoi(f[0]); err != nil {
return 0, 0, err
}
if right, err = strconv.Atoi(f[1]); err != nil {
return 0, 0, err
}
return left, right, nil
}
// commitFormat is the `git log --format` matching parseCommits: fields
// separated by US, records terminated by RS.
const commitFormat = "--format=%h%x1f%s%x1f%an%x1f%ct%x1e"
// parseCommits parses `git log` output produced with commitFormat.
func parseCommits(out string) []submodule.Commit {
var cs []submodule.Commit
for _, rec := range strings.Split(out, "\x1e") {
f := strings.Split(strings.TrimLeft(rec, "\n"), "\x1f")
if len(f) != 4 {
continue
}
c := submodule.Commit{SHA: f[0], Subject: f[1], Author: f[2]}
if sec, err := strconv.ParseInt(f[3], 10, 64); err == nil {
c.When = time.Unix(sec, 0)
}
cs = append(cs, c)
}
return cs
}
// parseGitlink returns the SHA of a gitlink (mode 160000) entry in
// `git ls-files -s` or `git ls-tree` output, or "" if there is none.
func parseGitlink(out string) string {
for _, line := range strings.Split(out, "\n") {
f := strings.Fields(line)
switch {
case len(f) >= 3 && f[0] == "160000" && f[1] == "commit": // ls-tree
return f[2]
case len(f) >= 2 && f[0] == "160000": // ls-files -s
return f[1]
}
}
return ""
}
+85
View File
@@ -0,0 +1,85 @@
package git
import (
"slices"
"testing"
"submarine/internal/submodule"
)
func TestParseGitmodules(t *testing.T) {
out := "submodule.src/core.path src/core\n" +
"submodule.src/core.url ../core.git\n" +
"submodule.lib.v2.path libs/v2\n" +
"submodule.lib.v2.branch develop\n" +
"submodule.lib.v2.url git@example.com:lib.git\n" +
"submodule.nopath.url x\n"
want := []gitmodule{
{name: "src/core", path: "src/core", url: "../core.git"},
{name: "lib.v2", path: "libs/v2", url: "git@example.com:lib.git", branch: "develop"},
}
if got := parseGitmodules(out); !slices.Equal(got, want) {
t.Errorf("parseGitmodules() = %+v, want %+v", got, want)
}
if got := parseGitmodules(""); got != nil {
t.Errorf("parseGitmodules(\"\") = %+v, want nil", got)
}
}
func TestParseWorktree(t *testing.T) {
tests := []struct {
out string
want submodule.Worktree
}{
{"", submodule.WorktreeClean},
{"? new.txt", submodule.WorktreeUntracked},
{"? new.txt\n1 .M N... 100644 100644 100644 a b README", submodule.WorktreeModified},
{"2 R. N... 100644 100644 100644 a b R100 new\told", submodule.WorktreeModified},
{"1 .M N... 100644 100644 100644 a b README\nu UU N... 1 2 3 4 a b c README", submodule.WorktreeConflict},
}
for _, tt := range tests {
if got := parseWorktree(tt.out); got != tt.want {
t.Errorf("parseWorktree(%q) = %v, want %v", tt.out, got, tt.want)
}
}
}
func TestParseLeftRight(t *testing.T) {
if l, r, err := parseLeftRight("3\t1"); err != nil || l != 3 || r != 1 {
t.Errorf("parseLeftRight = %d, %d, %v; want 3, 1, nil", l, r, err)
}
if _, _, err := parseLeftRight("3"); err == nil {
t.Error("parseLeftRight(\"3\") should fail")
}
}
func TestParseCommits(t *testing.T) {
out := "abc1234\x1fFix it\x1fAda\x1f1700000000\x1e\n" +
"def5678\x1fAdd\x1fBob\x1f1700000100\x1e\n" +
"malformed\x1e\n"
got := parseCommits(out)
want := []string{"abc1234", "def5678"}
if len(got) != len(want) {
t.Fatalf("parseCommits() = %+v, want SHAs %v", got, want)
}
for i, c := range got {
if c.SHA != want[i] {
t.Errorf("commit %d: SHA %q, want %q", i, c.SHA, want[i])
}
}
if got[0].Subject != "Fix it" || got[0].Author != "Ada" || got[0].When.Unix() != 1700000000 {
t.Errorf("parseCommits()[0] = %+v", got[0])
}
}
func TestParseGitlink(t *testing.T) {
if got := parseGitlink("160000 0123abc 0\tsrc/core"); got != "0123abc" {
t.Errorf("ls-files: got %q", got)
}
if got := parseGitlink("160000 commit 0123abc\tsrc/core"); got != "0123abc" {
t.Errorf("ls-tree: got %q", got)
}
if got := parseGitlink("100644 0123abc 0\tREADME"); got != "" {
t.Errorf("blob: got %q", got)
}
}
+375
View File
@@ -0,0 +1,375 @@
// Package git provides a read-only submodule.Provider backed by the git
// command line. It never touches the network.
package git
import (
"context"
"errors"
"fmt"
"io/fs"
"os"
"path"
"path/filepath"
"runtime"
"strings"
"sync"
"submarine/internal/submodule"
)
// maxContainsCandidates limits how many remote branches containing the pin
// are compared when resolving the pin by RefFromContains.
const maxContainsCandidates = 16
// maxBetween limits the commits listed between pin and tip.
const maxBetween = 50
// Provider reads a superproject and its submodules with git.
type Provider struct {
root string // absolute superproject root
sem chan struct{} // bounds concurrent Status calls
mu sync.RWMutex
mods map[string]info // by Submodule.Path; filled by Tree
}
// info is what Status needs about a submodule beyond its path.
type info struct {
superDir string // absolute path of the parent repository
dir string // absolute path of the submodule
relPath string // relative to superDir, slash-separated
branch string // branch from .gitmodules, may be "" or "."
}
// New returns a provider for the repository containing dir.
func New(dir string) (*Provider, error) {
root, err := run(context.Background(), dir, "rev-parse", "--show-toplevel")
if err != nil {
return nil, fmt.Errorf("%s is not a git repository", dir)
}
return &Provider{
root: root,
sem: make(chan struct{}, runtime.NumCPU()),
}, nil
}
func (p *Provider) Tree(ctx context.Context) (*submodule.Repo, error) {
repo := &submodule.Repo{Path: abbrevHome(p.root)}
if b, ok, err := try(ctx, p.root, "symbolic-ref", "--short", "-q", "HEAD"); err != nil {
return nil, err
} else if ok {
repo.Branch = b
} else {
repo.Branch = "(detached)"
}
if out, ok, err := try(ctx, p.root, "rev-list", "--left-right", "--count", "HEAD...@{upstream}"); err != nil {
return nil, err
} else if ok {
repo.Ahead, repo.Behind, _ = parseLeftRight(out)
}
if dir, ok, err := try(ctx, p.root, "rev-parse", "--path-format=absolute", "--git-common-dir"); err != nil {
return nil, err
} else if ok {
if fi, err := os.Stat(filepath.Join(dir, "FETCH_HEAD")); err == nil {
repo.FetchedAt = fi.ModTime()
}
}
mods := map[string]info{}
subs, err := p.build(ctx, p.root, nil, mods)
if err != nil {
return nil, err
}
repo.Submodules = subs
p.mu.Lock()
p.mods = mods
p.mu.Unlock()
return repo, nil
}
// build reads the .gitmodules of the repository at dir and recurses into
// initialized submodules.
func (p *Provider) build(ctx context.Context, dir string, parent *submodule.Submodule, mods map[string]info) ([]*submodule.Submodule, error) {
if _, err := os.Stat(filepath.Join(dir, ".gitmodules")); err != nil {
return nil, nil
}
out, _, err := try(ctx, dir, "config", "-f", ".gitmodules", "--get-regexp", `^submodule\..*\.(path|url|branch)$`)
if err != nil {
return nil, err
}
var subs []*submodule.Submodule
for _, g := range parseGitmodules(out) {
rel := path.Clean(g.path)
full := rel
if parent != nil {
full = path.Join(parent.Path, rel)
}
s := &submodule.Submodule{
Name: path.Base(rel),
Path: full,
RelPath: rel,
URL: g.url,
Parent: parent,
}
subDir := filepath.Join(dir, filepath.FromSlash(rel))
mods[full] = info{superDir: dir, dir: subDir, relPath: rel, branch: g.branch}
if initialized(subDir) {
if s.Children, err = p.build(ctx, subDir, s, mods); err != nil {
return nil, err
}
}
subs = append(subs, s)
}
return subs, nil
}
func (p *Provider) Status(ctx context.Context, path string) (*submodule.Status, error) {
select {
case p.sem <- struct{}{}:
defer func() { <-p.sem }()
case <-ctx.Done():
return nil, ctx.Err()
}
p.mu.RLock()
in, ok := p.mods[path]
p.mu.RUnlock()
if !ok {
return nil, fmt.Errorf("unknown submodule %q", path)
}
return status(ctx, in)
}
func status(ctx context.Context, in info) (*submodule.Status, error) {
// pin: index gitlink, compared with HEAD
out, err := run(ctx, in.superDir, "ls-files", "-s", "--", in.relPath)
if err != nil {
return nil, err
}
pin := parseGitlink(out)
if pin == "" {
return nil, errors.New("no gitlink in the superproject index")
}
out, _, err = try(ctx, in.superDir, "ls-tree", "HEAD", "--", in.relPath)
if err != nil {
return nil, err
}
st := &submodule.Status{PinStaged: parseGitlink(out) != pin}
// presence
if _, err := os.Stat(in.dir); errors.Is(err, fs.ErrNotExist) {
st.Presence = submodule.DirMissing
} else if !initialized(in.dir) {
st.Presence = submodule.NotInitialized
}
if st.Presence != submodule.PresenceOK {
st.Pinned = &submodule.Commit{SHA: pin[:min(7, len(pin))]}
return st, nil
}
// checkout and worktree
head, err := run(ctx, in.dir, "rev-parse", "HEAD")
if err != nil {
return nil, err
}
branch, onBranch, err := try(ctx, in.dir, "symbolic-ref", "--short", "-q", "HEAD")
if err != nil {
return nil, err
}
switch {
case head != pin:
st.Checkout = submodule.CheckoutDiffers
case !onBranch:
st.Checkout = submodule.CheckoutDetached
}
out, err = run(ctx, in.dir, "status", "--porcelain=v2", "--ignore-submodules=dirty")
if err != nil {
return nil, err
}
st.Worktree = parseWorktree(out)
// the pin must exist locally to say anything about it
if _, ok, err := try(ctx, in.dir, "cat-file", "-e", pin+"^{commit}"); err != nil {
return nil, err
} else if !ok {
return nil, fmt.Errorf("pinned commit %.7s not fetched", pin)
}
// remote branches containing the pin, oldest tip first
out, err = run(ctx, in.dir, "for-each-ref", "--contains", pin, "--sort=committerdate", "--format=%(refname)", "refs/remotes")
if err != nil {
return nil, err
}
var containing []string
for _, r := range strings.Fields(out) {
if !strings.HasSuffix(r, "/HEAD") {
containing = append(containing, strings.TrimPrefix(r, "refs/remotes/"))
}
}
st.Pin.Pushed = len(containing) > 0
ref, source, err := resolve(ctx, in, pin, branch, containing)
if err != nil {
return nil, err
}
st.Pin.Ref, st.Pin.Source = ref, source
// commits
if st.Pinned, err = commit(ctx, in.dir, pin); err != nil {
return nil, err
}
st.CheckedOut = st.Pinned
if head != pin {
if st.CheckedOut, err = commit(ctx, in.dir, head); err != nil {
return nil, err
}
}
if source == submodule.RefFromSHA {
st.Pin.Ref = st.Pinned.SHA
return st, nil
}
if st.Tip, err = commit(ctx, in.dir, ref); err != nil {
return nil, err
}
out, err = run(ctx, in.dir, "rev-list", "--left-right", "--count", pin+"..."+ref)
if err != nil {
return nil, err
}
if st.Pin.Ahead, st.Pin.Behind, err = parseLeftRight(out); err != nil {
return nil, err
}
if st.Pin.Ahead+st.Pin.Behind > 0 {
out, err = run(ctx, in.dir, "log", commitFormat, fmt.Sprintf("--max-count=%d", maxBetween), pin+"..."+ref)
if err != nil {
return nil, err
}
st.Between = parseCommits(out)
}
return st, nil
}
// resolve finds the ref that describes the pin, following the order in
// docs/state-model.md. containing lists the remote branches containing the
// pin, oldest tip first.
func resolve(ctx context.Context, in info, pin, branch string, containing []string) (string, submodule.RefSource, error) {
remote, err := remoteName(ctx, in.dir)
if err != nil {
return "", 0, err
}
// 1. branch from .gitmodules
if b := in.branch; b != "" && remote != "" {
if b == "." {
b, _, err = try(ctx, in.superDir, "symbolic-ref", "--short", "-q", "HEAD")
if err != nil {
return "", 0, err
}
}
if b != "" {
if _, ok, err := try(ctx, in.dir, "rev-parse", "--verify", "-q", "refs/remotes/"+remote+"/"+b); err != nil {
return "", 0, err
} else if ok {
return remote + "/" + b, submodule.RefFromGitmodules, nil
}
}
}
// 2. upstream of the checked-out branch
if branch != "" {
if up, ok, err := try(ctx, in.dir, "rev-parse", "--abbrev-ref", "--symbolic-full-name", branch+"@{upstream}"); err != nil {
return "", 0, err
} else if ok && up != "" {
return up, submodule.RefFromCurrentBranch, nil
}
}
// 3. remote default branch, if it contains the pin
if remote != "" {
if def, ok, err := try(ctx, in.dir, "symbolic-ref", "--short", "-q", "refs/remotes/"+remote+"/HEAD"); err != nil {
return "", 0, err
} else if ok {
if _, ok, err := try(ctx, in.dir, "merge-base", "--is-ancestor", pin, def); err != nil {
return "", 0, err
} else if ok {
return def, submodule.RefFromDefaultBranch, nil
}
}
}
// 4. remote branch containing the pin with the closest tip
best, bestN := "", -1
for _, r := range containing[:min(len(containing), maxContainsCandidates)] {
out, err := run(ctx, in.dir, "rev-list", "--count", pin+".."+r)
if err != nil {
return "", 0, err
}
var n int
if _, err := fmt.Sscan(out, &n); err == nil && (bestN < 0 || n < bestN) {
best, bestN = r, n
}
}
if best != "" {
return best, submodule.RefFromContains, nil
}
// 5. nearest tag
if tag, ok, err := try(ctx, in.dir, "describe", "--tags", "--abbrev=0", pin); err != nil {
return "", 0, err
} else if ok && tag != "" {
return tag, submodule.RefFromTag, nil
}
// 6. the commit itself
return "", submodule.RefFromSHA, nil
}
// remoteName returns "origin" if it exists, else the first remote, else "".
func remoteName(ctx context.Context, dir string) (string, error) {
out, err := run(ctx, dir, "remote")
if err != nil {
return "", err
}
remotes := strings.Fields(out)
for _, r := range remotes {
if r == "origin" {
return r, nil
}
}
if len(remotes) > 0 {
return remotes[0], nil
}
return "", nil
}
func commit(ctx context.Context, dir, rev string) (*submodule.Commit, error) {
out, err := run(ctx, dir, "log", "-1", commitFormat, rev, "--")
if err != nil {
return nil, err
}
cs := parseCommits(out)
if len(cs) == 0 {
return nil, fmt.Errorf("no commit %s", rev)
}
return &cs[0], nil
}
// initialized reports whether dir is a checked-out repository (.git file or
// directory present).
func initialized(dir string) bool {
_, err := os.Stat(filepath.Join(dir, ".git"))
return err == nil
}
func abbrevHome(p string) string {
home, err := os.UserHomeDir()
if err != nil || home == "" {
return p
}
if p == home {
return "~"
}
if rest, ok := strings.CutPrefix(p, home+string(filepath.Separator)); ok {
return "~/" + filepath.ToSlash(rest)
}
return p
}
+172
View File
@@ -0,0 +1,172 @@
package git
import (
"context"
"errors"
"os"
"os/exec"
"path/filepath"
"runtime"
"slices"
"testing"
"submarine/internal/submodule"
)
// fixtureDir is the superproject built by scripts/make-fixture.sh, or ""
// if git or bash is unavailable.
var fixtureDir string
func TestMain(m *testing.M) {
code := func() int {
tmp, err := os.MkdirTemp("", "submarine-fixture-")
if err != nil {
panic(err)
}
defer os.RemoveAll(tmp)
if _, err := exec.LookPath("git"); err == nil {
_, file, _, _ := runtime.Caller(0)
script := filepath.Join(filepath.Dir(file), "..", "..", "..", "scripts", "make-fixture.sh")
out, err := exec.Command("bash", script, filepath.Join(tmp, "fx")).CombinedOutput()
if err != nil {
panic("make-fixture.sh: " + err.Error() + "\n" + string(out))
}
fixtureDir = filepath.Join(tmp, "fx", "super")
}
return m.Run()
}()
os.Exit(code)
}
func loadFixture(t *testing.T) (*Provider, *submodule.Repo) {
t.Helper()
if fixtureDir == "" {
t.Skip("git not available")
}
p, err := New(fixtureDir)
if err != nil {
t.Fatal(err)
}
repo, err := p.Tree(context.Background())
if err != nil {
t.Fatal(err)
}
return p, repo
}
func TestTree(t *testing.T) {
_, repo := loadFixture(t)
if repo.Branch != "main" {
t.Errorf("Branch = %q, want main", repo.Branch)
}
if !repo.FetchedAt.IsZero() {
t.Errorf("FetchedAt = %v, want zero (superproject never fetched)", repo.FetchedAt)
}
var paths []string
submodule.Walk(repo.Submodules, func(s *submodule.Submodule) { paths = append(paths, s.Path) })
want := []string{
"src/core", "src/ui", "src/net", "libs/store", "libs/log", "libs/log/modules/nested",
"libs/feature", "libs/conflict", "libs/staged", "libs/missing", "vendor",
}
if !slices.Equal(paths, want) {
t.Errorf("paths = %v\nwant %v", paths, want)
}
log := repo.Submodules[4]
nested := log.Children[0]
if nested.Parent != log || nested.Name != "nested" || nested.RelPath != "modules/nested" {
t.Errorf("nested = %+v", nested)
}
}
func TestStatus(t *testing.T) {
p, _ := loadFixture(t)
tests := []struct {
path string
issues []submodule.IssueKind
ref string
source submodule.RefSource
ahead int
behind int
staged bool
checkout submodule.Checkout
}{
{path: "src/core", issues: []submodule.IssueKind{submodule.IssueOffPin}, ref: "origin/main", source: submodule.RefFromDefaultBranch, checkout: submodule.CheckoutDiffers},
{path: "src/ui", ref: "origin/main", source: submodule.RefFromCurrentBranch, behind: 2},
{path: "src/net", issues: []submodule.IssueKind{submodule.IssueUnpushed}, ref: "origin/main", source: submodule.RefFromCurrentBranch, ahead: 1},
{path: "libs/store", ref: "origin/develop", source: submodule.RefFromGitmodules},
{path: "libs/log", ref: "origin/main", source: submodule.RefFromCurrentBranch},
{path: "libs/log/modules/nested", issues: []submodule.IssueKind{submodule.IssueChanged}, ref: "origin/main", source: submodule.RefFromDefaultBranch, checkout: submodule.CheckoutDetached},
{path: "libs/feature", ref: "origin/feat/search", source: submodule.RefFromContains, behind: 1, checkout: submodule.CheckoutDetached},
{path: "libs/conflict", issues: []submodule.IssueKind{submodule.IssueConflict}, ref: "origin/main", source: submodule.RefFromCurrentBranch, staged: true},
{path: "libs/staged", ref: "origin/main", source: submodule.RefFromCurrentBranch, staged: true},
{path: "libs/missing", issues: []submodule.IssueKind{submodule.IssueMissing}},
{path: "vendor", issues: []submodule.IssueKind{submodule.IssueChanged}, ref: "origin/main", source: submodule.RefFromCurrentBranch, behind: 2},
}
for _, tt := range tests {
st, err := p.Status(context.Background(), tt.path)
if err != nil {
t.Errorf("%s: %v", tt.path, err)
continue
}
var kinds []submodule.IssueKind
for _, is := range st.Issues() {
kinds = append(kinds, is.Kind)
}
if !slices.Equal(kinds, tt.issues) {
t.Errorf("%s: issues %v, want %v", tt.path, kinds, tt.issues)
}
if st.Presence != submodule.PresenceOK {
continue
}
if st.Pin.Ref != tt.ref || st.Pin.Source != tt.source {
t.Errorf("%s: ref %q (%v), want %q (%v)", tt.path, st.Pin.Ref, st.Pin.Source, tt.ref, tt.source)
}
if st.Pin.Ahead != tt.ahead || st.Pin.Behind != tt.behind {
t.Errorf("%s: ↑%d↓%d, want ↑%d↓%d", tt.path, st.Pin.Ahead, st.Pin.Behind, tt.ahead, tt.behind)
}
if len(st.Between) != tt.ahead+tt.behind {
t.Errorf("%s: %d commits between, want %d", tt.path, len(st.Between), tt.ahead+tt.behind)
}
if st.PinStaged != tt.staged {
t.Errorf("%s: PinStaged %v, want %v", tt.path, st.PinStaged, tt.staged)
}
if st.Checkout != tt.checkout {
t.Errorf("%s: Checkout %v, want %v", tt.path, st.Checkout, tt.checkout)
}
if st.Pinned == nil || st.CheckedOut == nil || st.Tip == nil || st.Pinned.Author != "Ada Lovelace" {
t.Errorf("%s: commits %+v %+v %+v", tt.path, st.Pinned, st.CheckedOut, st.Tip)
}
}
}
func TestStatusMissingPresence(t *testing.T) {
p, _ := loadFixture(t)
st, err := p.Status(context.Background(), "libs/missing")
if err != nil {
t.Fatal(err)
}
if st.Presence != submodule.NotInitialized || st.Pinned == nil || len(st.Pinned.SHA) != 7 {
t.Errorf("libs/missing: %+v", st)
}
}
func TestStatusErrors(t *testing.T) {
p, _ := loadFixture(t)
if _, err := p.Status(context.Background(), "nope"); err == nil {
t.Error("unknown path: want error")
}
ctx, cancel := context.WithCancel(context.Background())
cancel()
if _, err := p.Status(ctx, "src/core"); !errors.Is(err, context.Canceled) {
t.Errorf("canceled: err = %v, want context.Canceled", err)
}
}
func TestNewNotARepo(t *testing.T) {
if _, err := exec.LookPath("git"); err != nil {
t.Skip("git not available")
}
if _, err := New(t.TempDir()); err == nil {
t.Error("New(non-repo): want error")
}
}
+4 -1
View File
@@ -55,8 +55,11 @@ func (t Theme) Delta(p submodule.PinInfo, base lipgloss.Style) string {
return s return s
} }
// Ago formats a time relative to now, e.g. "5m ago". // Ago formats a time relative to now, e.g. "5m ago"; the zero time is "never".
func Ago(t time.Time) string { func Ago(t time.Time) string {
if t.IsZero() {
return "never"
}
d := time.Since(t) d := time.Since(t)
switch { switch {
case d < time.Minute: case d < time.Minute:
+126
View File
@@ -0,0 +1,126 @@
#!/usr/bin/env bash
# Builds a superproject with submodules in every state submarine shows.
# Usage: scripts/make-fixture.sh <dir> (dir must not exist)
# The superproject is <dir>/super; bare "remotes" live in <dir>/remotes.
set -euo pipefail
[ $# -eq 1 ] || { echo "usage: $0 <dir>" >&2; exit 2; }
[ ! -e "$1" ] || { echo "$1 already exists" >&2; exit 2; }
mkdir -p "$1"
root=$(cd "$1" && pwd)
# Reproducible and isolated from the user's git config.
export GIT_CONFIG_GLOBAL=/dev/null GIT_CONFIG_NOSYSTEM=1
export GIT_CONFIG_COUNT=2
export GIT_CONFIG_KEY_0=protocol.file.allow GIT_CONFIG_VALUE_0=always
export GIT_CONFIG_KEY_1=init.defaultBranch GIT_CONFIG_VALUE_1=main
export GIT_AUTHOR_NAME="Ada Lovelace" GIT_AUTHOR_EMAIL=ada@example.com
export GIT_COMMITTER_NAME="Ada Lovelace" GIT_COMMITTER_EMAIL=ada@example.com
g() { git -C "$@" >/dev/null 2>&1; }
# commit <repo> <file> <message>
commit() {
echo "$3" >>"$1/$2"
g "$1" add "$2"
g "$1" commit -m "$3"
}
# remote <name>: bare repo with two commits on main, pushed from a work clone
remote() {
git init -q --bare "$root/remotes/$1.git"
git clone -q "$root/remotes/$1.git" "$root/work/$1" 2>/dev/null
commit "$root/work/$1" README "Initial $1"
commit "$root/work/$1" README "Add docs to $1"
g "$root/work/$1" push origin main
}
# upstream <name> <message>: a new commit on the remote's main
upstream() {
commit "$root/work/$1" README "$2"
g "$root/work/$1" push origin main
}
mkdir -p "$root/remotes" "$root/work"
for r in core ui net store log feature nested; do remote "$r"; done
# "feature" has a branch that main doesn't contain
g "$root/work/feature" switch -c feat/search
commit "$root/work/feature" search "Add search"
commit "$root/work/feature" search "Tune search"
g "$root/work/feature" push origin feat/search
g "$root/work/feature" switch main
# "store" has a develop branch for the .gitmodules branch rule
g "$root/work/store" switch -c develop
commit "$root/work/store" dev "Start develop"
g "$root/work/store" push origin develop
g "$root/work/store" switch main
# "nested" becomes a submodule of "log"
g "$root/work/log" submodule add "$root/remotes/nested.git" modules/nested
g "$root/work/log" commit -m "Add nested"
g "$root/work/log" push origin main
super="$root/super"
git init -q "$super"
commit "$super" README "Initial superproject"
add() { g "$super" submodule add "${@:3}" "$root/remotes/$1.git" "$2"; }
add core src/core
add ui src/ui
add net src/net
add store libs/store -b develop
add log libs/log
add feature libs/feature
add core libs/conflict
add ui libs/staged
add net libs/missing
add ui vendor
g "$super" submodule update --init --recursive
g "$super" commit -m "Add submodules"
# src/ui: remote moved on, fetched -> pin behind (↓2)
upstream ui "Fix layout"
upstream ui "Add theme"
g "$super/src/ui" fetch
g "$super/vendor" fetch
g "$super/libs/staged" fetch
# src/net: local commit pinned but never pushed -> unpushed
commit "$super/src/net" NOTES "Local experiment"
g "$super" add src/net
g "$super" commit -m "Pin net experiment"
# libs/feature: pinned inside feat/search only -> ref from contains (↓1)
g "$super/libs/feature" fetch
g "$super/libs/feature" checkout origin/feat/search~1
g "$super" add libs/feature
g "$super" commit -m "Pin feature search"
# src/core: checkout moved away from the pin -> off pin
g "$super/src/core" checkout HEAD~1
# libs/log/modules/nested: uncommitted change, hidden under a collapsed parent
echo dirty >>"$super/libs/log/modules/nested/README"
# vendor: untracked file
echo scratch >"$super/vendor/scratch.txt"
# libs/staged: new pin staged in the superproject, not committed
g "$super/libs/staged" merge --ff-only origin/main
g "$super" add libs/staged
# libs/conflict: merge conflict in the worktree
g "$super/libs/conflict" switch -c left
commit "$super/libs/conflict" README "Left change"
g "$super/libs/conflict" switch main
commit "$super/libs/conflict" README "Right change"
g "$super/libs/conflict" push origin main
git -C "$super/libs/conflict" merge left >/dev/null 2>&1 || true
# keep the pin on the checkout so only the conflict shows
g "$super" add libs/conflict
# libs/missing: deinitialized -> missing
g "$super" submodule deinit -f libs/missing
echo "$super"