Archived
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>
270 lines
9.1 KiB
Go
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")
|
|
}
|
|
}
|