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 f287d6009f Draw diverged commits on the spine as a fork
A tip, checkout or committed pin that has diverged from the pin gets its
own lane, which joins the main lane at a "base" node: the merge base of
the two, with its SHA and subject. Only one diverged commit is drawn as a
fork; with two, or without a merge base, the nodes stay in a line with
the "diverged" gap.

Add Status.Bases, filled by the git provider with `merge-base` for each
diverged commit.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-23 16:40:25 +02:00

344 lines
12 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)
}
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
drift submodule.Distance // checkout compared with the pin
}{
{path: "src/core", issues: []submodule.IssueKind{submodule.IssueOffPin}, ref: "origin/main", source: submodule.RefFromDefaultBranch, checkout: submodule.CheckoutDiffers, drift: submodule.Distance{Behind: 1}},
{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 st.PinStaged != tt.staged {
t.Errorf("%s: PinStaged %v, want %v", tt.path, st.PinStaged, tt.staged)
}
if tt.staged != (st.Committed != nil) {
t.Errorf("%s: Committed = %+v with staged = %v", tt.path, st.Committed, tt.staged)
}
if tt.path == "libs/staged" && (st.CommittedDrift.Ahead != 0 || st.CommittedDrift.Behind == 0) {
t.Errorf("%s: CommittedDrift %+v, want the committed pin behind the staged one", tt.path, st.CommittedDrift)
}
if st.Drift != tt.drift {
t.Errorf("%s: drift %+v, want %+v", tt.path, st.Drift, tt.drift)
}
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")
}
}
// A staged pin older than the committed one: the committed pin is ahead.
func TestCommittedPinAheadOfStaged(t *testing.T) {
p, root := ownFixture(t)
super := filepath.Join(root, "super")
git(t, filepath.Join(super, "src", "ui"), "checkout", "-q", "--detach", "HEAD~1")
git(t, super, "add", "src/ui")
st, err := p.Status(context.Background(), "src/ui")
if err != nil {
t.Fatal(err)
}
if !st.PinStaged || st.Committed == nil {
t.Fatalf("PinStaged = %v, Committed = %v", st.PinStaged, st.Committed)
}
if st.CommittedDrift != (submodule.Distance{Ahead: 1}) {
t.Errorf("CommittedDrift = %+v, want the committed pin 1 ahead", st.CommittedDrift)
}
spine := st.Spine()
if n := len(spine); n < 2 || spine[n-1].Commit.SHA != st.Pinned.SHA || spine[n-2].Commit.SHA != st.Committed.SHA {
t.Errorf("spine = %+v, want the committed pin directly above the staged one", spine)
}
}
// A pin and its ref that each have a commit the other lacks, and a checkout
// that did the same to the pin: both get the merge base.
func TestBasesOfDivergedCommits(t *testing.T) {
p, root := ownFixture(t)
ctx := context.Background()
super := filepath.Join(root, "super")
// src/net: the pin is unpushed; upstream moved on
pushUpstream(t, root, "net")
if err := p.Fetch(ctx, "src/net"); err != nil {
t.Fatal(err)
}
st, err := p.Status(ctx, "src/net")
if err != nil {
t.Fatal(err)
}
if st.Pin.Ahead != 1 || st.Pin.Behind != 1 {
t.Fatalf("src/net: ↑%d↓%d, want ↑1↓1", st.Pin.Ahead, st.Pin.Behind)
}
if b := st.Bases.Tip; b == nil || b.SHA == st.Pinned.SHA || b.SHA == st.Tip.SHA {
t.Errorf("src/net: Bases.Tip = %+v, want a commit before the pin and the tip", b)
}
if sp := st.Spine(); len(sp) != 3 || !sp[0].Fork || sp[0].Commit.SHA != st.Tip.SHA {
t.Errorf("src/net: spine = %+v, want a fork headed by the tip", sp)
}
// src/core: a commit on top of a checkout that is behind the pin
git(t, filepath.Join(super, "src", "core"), "commit", "--allow-empty", "-m", "Local")
st, err = p.Status(ctx, "src/core")
if err != nil {
t.Fatal(err)
}
if st.Drift != (submodule.Distance{Ahead: 1, Behind: 1}) {
t.Fatalf("src/core: drift %+v, want 1/1", st.Drift)
}
if b := st.Bases.Checkout; b == nil || b.SHA == st.Pinned.SHA || b.SHA == st.CheckedOut.SHA {
t.Errorf("src/core: Bases.Checkout = %+v", b)
}
// in sync and linear: no bases
if st, err = p.Status(ctx, "src/ui"); err != nil {
t.Fatal(err)
} else if st.Bases != (submodule.Bases{}) {
t.Errorf("src/ui: Bases = %+v, want none", st.Bases)
}
}