This repository has been archived on 2026-10-25. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
submarine/internal/submodule/git/provider_test.go
T
simonfrankenandClaude Sonnet 5.5 8aa12774eb Resolve pins to the closest branch
A pin on a pushed feature branch showed as diverged from origin/main
because the .gitmodules branch won before any distance was compared.

Now the branch containing the pin with the fewest commits after it wins;
the .gitmodules branch, the upstream and the remote default branch only
break ties and are always compared. An unpushed pin compares just those
preferred refs by commits on either side.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-21 20:57:55 +02:00

270 lines
9.1 KiB
Go

package git
import (
"context"
"errors"
"fmt"
"os"
"os/exec"
"path/filepath"
"runtime"
"slices"
"testing"
"time"
"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 {
if err := makeFixture(filepath.Join(tmp, "fx")); err != nil {
panic(err)
}
fixtureDir = filepath.Join(tmp, "fx", "super")
}
return m.Run()
}()
os.Exit(code)
}
// makeFixture runs scripts/make-fixture.sh; the superproject is dir/super.
func makeFixture(dir string) error {
_, file, _, _ := runtime.Caller(0)
script := filepath.Join(filepath.Dir(file), "..", "..", "..", "scripts", "make-fixture.sh")
if out, err := exec.Command("bash", script, dir).CombinedOutput(); err != nil {
return fmt.Errorf("make-fixture.sh: %v\n%s", err, out)
}
return nil
}
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", "libs/work", "libs/topic",
}
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/topic", ref: "origin/feat/search", source: submodule.RefFromContains, 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},
{path: "libs/work", issues: []submodule.IssueKind{submodule.IssueChanged}, ref: "origin/main", source: submodule.RefFromCurrentBranch},
}
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 TestChanges(t *testing.T) {
p, _ := loadFixture(t)
ctx := context.Background()
st, err := p.Status(ctx, "libs/work")
if err != nil {
t.Fatal(err)
}
c := st.Changes
if c == nil {
t.Fatal("libs/work: Changes = nil")
}
want := []submodule.FileChange{
{Path: "main.go", Kind: submodule.ChangeModified, Added: 6, Removed: 2},
{Path: "util.go", Kind: submodule.ChangeModified, Added: 1},
{Path: "logo.png", Kind: submodule.ChangeModified, Binary: true},
{Path: "notes_new.go", Kind: submodule.ChangeAdded, Added: 1},
{Path: "old.go", Kind: submodule.ChangeRemoved, Removed: 8},
{Path: "lib2.go", OrigPath: "lib.go", Kind: submodule.ChangeRenamed},
{Path: "scratch.txt", Kind: submodule.ChangeUntracked},
{Path: "tmp/", Kind: submodule.ChangeUntracked},
}
for i, f := range c.Files {
if f.Modified.IsZero() != (f.Kind == submodule.ChangeRemoved) {
t.Errorf("libs/work: %s: Modified %v", f.Path, f.Modified)
}
c.Files[i].Modified = time.Time{}
}
if !slices.Equal(c.Files, want) {
t.Errorf("libs/work: Files =\n%+v\nwant\n%+v", c.Files, want)
}
if c.Total != 8 || c.Added != 8 || c.Removed != 10 {
t.Errorf("libs/work: Total %d, +%d -%d; want 8, +8 -10", c.Total, c.Added, c.Removed)
}
if n := c.Counts(nil); n[submodule.ChangeModified] != 3 || n[submodule.ChangeUntracked] != 2 {
t.Errorf("libs/work: Counts = %v", n)
}
for _, path := range []string{"libs/log", "src/ui"} {
st, err := p.Status(ctx, path)
if err != nil {
t.Fatal(err)
}
if st.Changes != nil {
t.Errorf("%s: clean submodule has Changes %+v", path, st.Changes)
}
}
st, err = p.Status(ctx, "libs/conflict")
if err != nil {
t.Fatal(err)
}
if st.Changes == nil || len(st.Changes.Files) != 1 || st.Changes.Files[0].Kind != submodule.ChangeConflicted {
t.Errorf("libs/conflict: Changes = %+v", st.Changes)
}
}
func TestChangesCap(t *testing.T) {
if _, err := exec.LookPath("git"); err != nil {
t.Skip("git not available")
}
dir := t.TempDir()
for _, args := range [][]string{{"init", "-q"}, {"-c", "user.name=t", "-c", "user.email=t@t", "commit", "-q", "--allow-empty", "-m", "init"}} {
if out, err := exec.Command("git", append([]string{"-C", dir}, args...)...).CombinedOutput(); err != nil {
t.Fatalf("git %v: %v\n%s", args, err, out)
}
}
n := submodule.MaxChangeFiles + 5
for i := range n {
if err := os.WriteFile(filepath.Join(dir, fmt.Sprintf("f%03d.txt", i)), []byte("x"), 0o644); err != nil {
t.Fatal(err)
}
}
out, err := run(context.Background(), dir, "status", "--porcelain=v2", "-z", "--untracked-files=normal")
if err != nil {
t.Fatal(err)
}
c, err := changes(context.Background(), dir, parseStatus(out))
if err != nil {
t.Fatal(err)
}
if c.Total != n || len(c.Files) != submodule.MaxChangeFiles || c.Files[0].Path != "f000.txt" {
t.Errorf("Total %d, len %d, first %q; want %d, %d, f000.txt", c.Total, len(c.Files), c.Files[0].Path, n, submodule.MaxChangeFiles)
}
}
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")
}
}