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
+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")
}
}