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