Archived
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>
344 lines
12 KiB
Go
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)
|
|
}
|
|
}
|