ajhahn.de
← eeco
Go 570 lines
package gitx

import (
	"os"
	"os/exec"
	"path/filepath"
	"regexp"
	"sort"
	"strings"
	"testing"
)

func TestAvailable(t *testing.T) {
	_, look := exec.LookPath("git")
	if Available() != (look == nil) {
		t.Errorf("Available()=%v but LookPath err=%v", Available(), look)
	}
}

func TestTrackedFiles(t *testing.T) {
	if _, err := exec.LookPath("git"); err != nil {
		t.Skip("git not available")
	}
	root := t.TempDir()
	for _, f := range []string{"a.txt", "sub/b.txt"} {
		p := filepath.Join(root, f)
		if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
			t.Fatal(err)
		}
		if err := os.WriteFile(p, []byte("x"), 0o644); err != nil {
			t.Fatal(err)
		}
	}
	for _, args := range [][]string{
		{"init", "-q"}, {"config", "user.email", "t@x"}, {"config", "user.name", "t"}, {"add", "-A"},
	} {
		c := exec.Command("git", args...)
		c.Dir = root
		if out, err := c.CombinedOutput(); err != nil {
			t.Fatalf("git %v: %v\n%s", args, err, out)
		}
	}
	// Untracked file must not appear.
	if err := os.WriteFile(filepath.Join(root, "c.txt"), []byte("y"), 0o644); err != nil {
		t.Fatal(err)
	}
	got, err := TrackedFiles(root)
	if err != nil {
		t.Fatal(err)
	}
	sort.Strings(got)
	want := []string{"a.txt", "sub/b.txt"}
	if len(got) != len(want) || got[0] != want[0] || got[1] != want[1] {
		t.Errorf("TrackedFiles = %v, want %v", got, want)
	}
}

func TestLatestSemverTag(t *testing.T) {
	if _, err := exec.LookPath("git"); err != nil {
		t.Skip("git not available")
	}
	t.Run("no-tags", func(t *testing.T) {
		work := t.TempDir()
		runGit(t, work, "init", "-q")
		runGit(t, work, "config", "user.email", "t@x")
		runGit(t, work, "config", "user.name", "t")
		if err := os.WriteFile(filepath.Join(work, "f.txt"), []byte("x"), 0o644); err != nil {
			t.Fatal(err)
		}
		runGit(t, work, "add", "-A")
		runGit(t, work, "commit", "-q", "-m", "init")
		got, err := LatestSemverTag(work)
		if err != nil {
			t.Fatal(err)
		}
		if got != "" {
			t.Errorf("LatestSemverTag with no tags = %q, want empty", got)
		}
	})
	t.Run("ranks-semver-tags", func(t *testing.T) {
		work := t.TempDir()
		runGit(t, work, "init", "-q")
		runGit(t, work, "config", "user.email", "t@x")
		runGit(t, work, "config", "user.name", "t")
		if err := os.WriteFile(filepath.Join(work, "f.txt"), []byte("x"), 0o644); err != nil {
			t.Fatal(err)
		}
		runGit(t, work, "add", "-A")
		runGit(t, work, "commit", "-q", "-m", "init")
		// Create tags out of insertion order; --sort=-v:refname must
		// rank v1.10.0 > v1.9.1 > v0.1.0 (string-sort would put 0.1.0
		// last but 1.10.0 before 1.9.1 — only v:refname gets the order
		// right).
		runGit(t, work, "tag", "v0.1.0")
		runGit(t, work, "tag", "v1.9.1")
		runGit(t, work, "tag", "v1.10.0")
		got, err := LatestSemverTag(work)
		if err != nil {
			t.Fatal(err)
		}
		if got != "v1.10.0" {
			t.Errorf("LatestSemverTag = %q, want v1.10.0", got)
		}
	})
	t.Run("skips-non-semver-shaped", func(t *testing.T) {
		work := t.TempDir()
		runGit(t, work, "init", "-q")
		runGit(t, work, "config", "user.email", "t@x")
		runGit(t, work, "config", "user.name", "t")
		if err := os.WriteFile(filepath.Join(work, "f.txt"), []byte("x"), 0o644); err != nil {
			t.Fatal(err)
		}
		runGit(t, work, "add", "-A")
		runGit(t, work, "commit", "-q", "-m", "init")
		runGit(t, work, "tag", "v1.0.0")
		runGit(t, work, "tag", "v1.0.0-rc1")
		runGit(t, work, "tag", "v2024.05.22")
		got, err := LatestSemverTag(work)
		if err != nil {
			t.Fatal(err)
		}
		// v2024.05.22 sorts high under -v:refname but is shape-valid
		// (three dot-separated unsigned ints), so it wins. The pre-
		// release tag v1.0.0-rc1 is filtered out.
		if got != "v2024.05.22" {
			t.Errorf("LatestSemverTag = %q, want v2024.05.22 (vX.Y.Z shape, pre-release filtered)", got)
		}
	})
	t.Run("no-commit", func(t *testing.T) {
		// No HEAD yet: `git tag --list --merged HEAD` exits non-zero with a
		// "malformed object name HEAD" stderr, which the ExitError branch
		// treats as "no semver tag reachable" — empty result, nil error.
		work := t.TempDir()
		runGit(t, work, "init", "-q")
		got, err := LatestSemverTag(work)
		if err != nil {
			t.Fatalf("LatestSemverTag on a no-commit repo: %v", err)
		}
		if got != "" {
			t.Errorf("LatestSemverTag = %q, want empty on a no-commit repo", got)
		}
	})
}

func TestSemverTags(t *testing.T) {
	if _, err := exec.LookPath("git"); err != nil {
		t.Skip("git not available")
	}
	commit := func(t *testing.T) string {
		t.Helper()
		work := t.TempDir()
		runGit(t, work, "init", "-q")
		runGit(t, work, "config", "user.email", "t@x")
		runGit(t, work, "config", "user.name", "t")
		if err := os.WriteFile(filepath.Join(work, "f.txt"), []byte("x"), 0o644); err != nil {
			t.Fatal(err)
		}
		runGit(t, work, "add", "-A")
		runGit(t, work, "commit", "-q", "-m", "init")
		return work
	}

	t.Run("no-tags", func(t *testing.T) {
		got, err := SemverTags(commit(t))
		if err != nil {
			t.Fatal(err)
		}
		if len(got) != 0 {
			t.Errorf("SemverTags with no tags = %v, want empty", got)
		}
	})

	t.Run("ranks-and-filters", func(t *testing.T) {
		work := commit(t)
		// Insertion order deliberately scrambled; --sort=-v:refname must
		// rank v1.10.0 > v1.9.1 > v0.1.0. The pre-release and the foreign
		// non-semver-shaped tag are filtered out.
		runGit(t, work, "tag", "v1.9.1")
		runGit(t, work, "tag", "v0.1.0")
		runGit(t, work, "tag", "v1.10.0")
		runGit(t, work, "tag", "v1.0.0-rc1")
		runGit(t, work, "tag", "nightly")
		got, err := SemverTags(work)
		if err != nil {
			t.Fatal(err)
		}
		want := []string{"v1.10.0", "v1.9.1", "v0.1.0"}
		if len(got) != len(want) || got[0] != want[0] || got[1] != want[1] || got[2] != want[2] {
			t.Errorf("SemverTags = %v, want %v (descending, non-semver filtered)", got, want)
		}
	})

	t.Run("no-commit", func(t *testing.T) {
		// Same ExitError early-return as LatestSemverTag: no HEAD → empty
		// slice, nil error.
		work := t.TempDir()
		runGit(t, work, "init", "-q")
		got, err := SemverTags(work)
		if err != nil {
			t.Fatalf("SemverTags on a no-commit repo: %v", err)
		}
		if len(got) != 0 {
			t.Errorf("SemverTags = %v, want empty on a no-commit repo", got)
		}
	})
}

func TestLastCommitDate(t *testing.T) {
	if _, err := exec.LookPath("git"); err != nil {
		t.Skip("git not available")
	}
	work := t.TempDir()
	runGit(t, work, "init", "-q")
	runGit(t, work, "config", "user.email", "t@x")
	runGit(t, work, "config", "user.name", "t")
	if err := os.WriteFile(filepath.Join(work, "a.txt"), []byte("x"), 0o644); err != nil {
		t.Fatal(err)
	}
	runGit(t, work, "add", "a.txt")
	runGit(t, work, "commit", "-q", "-m", "add a.txt")

	t.Run("committed-file", func(t *testing.T) {
		date, ok, err := LastCommitDate(work, "a.txt")
		if err != nil {
			t.Fatal(err)
		}
		if !ok {
			t.Fatal("ok = false for a committed file, want true")
		}
		if date.IsZero() {
			t.Error("date is zero for a committed file")
		}
	})

	t.Run("untracked-file", func(t *testing.T) {
		if err := os.WriteFile(filepath.Join(work, "b.txt"), []byte("y"), 0o644); err != nil {
			t.Fatal(err)
		}
		_, ok, err := LastCommitDate(work, "b.txt")
		if err != nil {
			t.Fatal(err)
		}
		if ok {
			t.Error("ok = true for an untracked file, want false")
		}
	})

	t.Run("nonexistent-path", func(t *testing.T) {
		_, ok, err := LastCommitDate(work, "never/existed.txt")
		if err != nil {
			t.Fatal(err)
		}
		if ok {
			t.Error("ok = true for a path with no history, want false")
		}
	})
}

// runGit is a small local helper shared by the LatestSemverTag and
// RemoteTags tests.
func runGit(t *testing.T, dir string, args ...string) {
	t.Helper()
	c := exec.Command("git", args...)
	c.Dir = dir
	if out, err := c.CombinedOutput(); err != nil {
		t.Fatalf("git %v: %v\n%s", args, err, out)
	}
}

func TestRemoteTags(t *testing.T) {
	if _, err := exec.LookPath("git"); err != nil {
		t.Skip("git not available")
	}
	bare := t.TempDir()
	work := t.TempDir()
	run := func(dir string, args ...string) {
		t.Helper()
		c := exec.Command("git", args...)
		c.Dir = dir
		if out, err := c.CombinedOutput(); err != nil {
			t.Fatalf("git %v: %v\n%s", args, err, out)
		}
	}
	run(bare, "init", "-q", "--bare")
	run(work, "init", "-q")
	run(work, "config", "user.email", "t@x")
	run(work, "config", "user.name", "t")
	if err := os.WriteFile(filepath.Join(work, "f.txt"), []byte("x"), 0o644); err != nil {
		t.Fatal(err)
	}
	run(work, "add", "-A")
	run(work, "commit", "-q", "-m", "init")
	// A lightweight and an annotated tag: the annotated one emits a
	// peeled `^{}` ref that must collapse to the bare name.
	run(work, "tag", "v0.1.0")
	run(work, "tag", "-a", "v0.2.0", "-m", "release")
	run(work, "remote", "add", "origin", bare)
	run(work, "push", "-q", "origin", "HEAD", "--tags")

	tags, err := RemoteTags(work, "")
	if err != nil {
		t.Fatalf("RemoteTags: %v", err)
	}
	sort.Strings(tags)
	want := []string{"v0.1.0", "v0.2.0"}
	if len(tags) != len(want) || tags[0] != want[0] || tags[1] != want[1] {
		t.Errorf("RemoteTags = %v, want %v (peeled refs must dedupe)", tags, want)
	}

	// An explicit remote (here the bare repo path) is queried directly,
	// independent of the working directory's default remote.
	explicit, err := RemoteTags(t.TempDir(), bare)
	if err != nil {
		t.Fatalf("RemoteTags explicit: %v", err)
	}
	sort.Strings(explicit)
	if len(explicit) != len(want) || explicit[0] != want[0] || explicit[1] != want[1] {
		t.Errorf("RemoteTags(explicit) = %v, want %v", explicit, want)
	}
}

// shaRE matches a full 40-char lowercase-hex git object name.
var shaRE = regexp.MustCompile(`^[0-9a-f]{40}$`)

// gitOut runs git at dir and returns trimmed-of-nothing stdout, failing the
// test on any error. It is the read-back companion to runGit.
func gitOut(t *testing.T, dir string, args ...string) string {
	t.Helper()
	c := exec.Command("git", args...)
	c.Dir = dir
	out, err := c.Output()
	if err != nil {
		t.Fatalf("git %v: %v", args, err)
	}
	return string(out)
}

// initRepo creates a fresh temp repo with an identity and one commit, and
// returns its root. Built on the existing runGit helper.
func initRepo(t *testing.T) string {
	t.Helper()
	root := t.TempDir()
	runGit(t, root, "init", "-q")
	runGit(t, root, "config", "user.email", "t@x")
	runGit(t, root, "config", "user.name", "t")
	commitFile(t, root, "base.txt", "base", "init")
	return root
}

// commitFile writes name=content under root, stages everything, commits with
// msg, and returns the new HEAD sha.
func commitFile(t *testing.T, root, name, content, msg string) string {
	t.Helper()
	if err := os.WriteFile(filepath.Join(root, name), []byte(content), 0o644); err != nil {
		t.Fatal(err)
	}
	runGit(t, root, "add", "-A")
	runGit(t, root, "commit", "-q", "-m", msg)
	return headSHA(t, root)
}

// headSHA returns the trimmed test-side HEAD sha at root.
func headSHA(t *testing.T, root string) string {
	t.Helper()
	return strings.TrimSpace(gitOut(t, root, "rev-parse", "HEAD"))
}

// fingerprint captures six read-only state fields as one comparable string.
// Each field changes only if a git call mutated the repo, so comparing the
// fingerprint before vs after a batch of gitx calls proves the calls were
// read-only (host-config differences cancel because both sides see the same
// config). `config` is pinned to --local so global config never leaks in.
func fingerprint(t *testing.T, root string) string {
	t.Helper()
	var b strings.Builder
	for _, args := range [][]string{
		{"rev-parse", "HEAD"},
		{"status", "--porcelain"},
		{"tag", "--list"},
		{"rev-list", "--all", "--count"},
		{"config", "--list", "--local"},
		{"stash", "list"},
	} {
		b.WriteString(strings.Join(args, " "))
		b.WriteString("\x1f")
		b.WriteString(gitOut(t, root, args...))
		b.WriteString("\x1e")
	}
	return b.String()
}

func TestUserName(t *testing.T) {
	if _, err := exec.LookPath("git"); err != nil {
		t.Skip("git not available")
	}
	t.Run("set", func(t *testing.T) {
		root := initRepo(t)
		runGit(t, root, "config", "user.name", "Ada Lovelace")
		got, err := UserName(root)
		if err != nil {
			t.Fatalf("UserName: %v", err)
		}
		if got != "Ada Lovelace" {
			t.Errorf("UserName = %q, want %q", got, "Ada Lovelace")
		}
	})
	t.Run("unset", func(t *testing.T) {
		// Null out global+system config so the host's own user.name cannot
		// leak in: an unset key must exit non-zero, which the ExitError
		// branch reports as "", nil (not an error).
		t.Setenv("GIT_CONFIG_GLOBAL", filepath.Join(t.TempDir(), "noglobal"))
		t.Setenv("GIT_CONFIG_SYSTEM", filepath.Join(t.TempDir(), "nosystem"))
		root := t.TempDir()
		runGit(t, root, "init", "-q")
		got, err := UserName(root)
		if err != nil {
			t.Fatalf("UserName with no user.name set returned an error: %v", err)
		}
		if got != "" {
			t.Errorf("UserName = %q, want empty when user.name is unset", got)
		}
	})
	t.Run("dir-does-not-exist", func(t *testing.T) {
		// A nonexistent cmd.Dir fails the chdir in Start() with a non-
		// ExitError, exercising wrap's else branch.
		root := filepath.Join(t.TempDir(), "nope")
		_, err := UserName(root)
		if err == nil {
			t.Fatal("UserName on a nonexistent dir: want an error, got nil")
		}
		if !strings.Contains(err.Error(), "git config user.name") {
			t.Errorf("err = %q, want it to contain %q", err.Error(), "git config user.name")
		}
	})
}

func TestHeadSHA(t *testing.T) {
	if _, err := exec.LookPath("git"); err != nil {
		t.Skip("git not available")
	}
	t.Run("committed", func(t *testing.T) {
		root := initRepo(t)
		got, err := HeadSHA(root)
		if err != nil {
			t.Fatalf("HeadSHA: %v", err)
		}
		if !shaRE.MatchString(got) {
			t.Errorf("HeadSHA = %q, want a 40-char lowercase-hex sha", got)
		}
	})
	t.Run("empty-repo", func(t *testing.T) {
		// `git rev-parse HEAD` with no commits exits 128 with stderr,
		// exercising wrap's stderr branch.
		root := t.TempDir()
		runGit(t, root, "init", "-q")
		_, err := HeadSHA(root)
		if err == nil {
			t.Fatal("HeadSHA on an empty repo: want an error, got nil")
		}
		if !strings.Contains(err.Error(), "git rev-parse") {
			t.Errorf("err = %q, want it to contain %q", err.Error(), "git rev-parse")
		}
	})
}

func TestChangesSince(t *testing.T) {
	if _, err := exec.LookPath("git"); err != nil {
		t.Skip("git not available")
	}
	t.Run("since-sha", func(t *testing.T) {
		root := initRepo(t)
		first := headSHA(t, root)
		// A second commit with different content so first..HEAD yields a
		// non-empty diffstat as well as a log line.
		commitFile(t, root, "f.txt", "v2", "second")
		log, stat, err := ChangesSince(root, first)
		if err != nil {
			t.Fatalf("ChangesSince: %v", err)
		}
		if !strings.Contains(log, "second") {
			t.Errorf("log = %q, want it to contain %q", log, "second")
		}
		if stat == "" {
			t.Error("stat is empty, want a non-empty diffstat for two differing commits")
		}
	})
	t.Run("since-empty", func(t *testing.T) {
		root := initRepo(t)
		log, _, err := ChangesSince(root, "")
		if err != nil {
			t.Fatalf("ChangesSince: %v", err)
		}
		if log == "" {
			t.Error("log is empty for the HEAD range, want non-empty")
		}
	})
	t.Run("bad-dir", func(t *testing.T) {
		root := filepath.Join(t.TempDir(), "nope")
		_, _, err := ChangesSince(root, "")
		if err == nil {
			t.Fatal("ChangesSince on a nonexistent dir: want an error, got nil")
		}
		if !strings.Contains(err.Error(), "git log") {
			t.Errorf("err = %q, want it to contain %q", err.Error(), "git log")
		}
	})
}

// TestReadOnly_StateFingerprintUnchanged is the H1.6 trust-boundary keystone
// seed: it proves the engine's git helpers never mutate a repository. Every
// public gitx function is called against a populated fixture, and a six-field
// read-only state snapshot taken before and after must be byte-identical. A
// future change that smuggles in a mutating subcommand trips this test. H1.6
// extends this suite — keep it named.
func TestReadOnly_StateFingerprintUnchanged(t *testing.T) {
	if _, err := exec.LookPath("git"); err != nil {
		t.Skip("git not available")
	}
	work := initRepo(t)
	beforeSHA := headSHA(t, work)
	// A lightweight and an annotated tag, plus a bare remote with the refs
	// pushed, so RemoteTags and the semver-tag queries have real refs to read.
	runGit(t, work, "tag", "v0.1.0")
	runGit(t, work, "tag", "-a", "v0.2.0", "-m", "release")
	bare := t.TempDir()
	runGit(t, bare, "init", "-q", "--bare")
	runGit(t, work, "remote", "add", "origin", bare)
	runGit(t, work, "push", "-q", "origin", "HEAD", "--tags")

	before := fingerprint(t, work)

	// Exercise every public function purely for its read side-effect.
	Available()
	if _, err := UserName(work); err != nil {
		t.Fatalf("UserName: %v", err)
	}
	if _, err := TrackedFiles(work); err != nil {
		t.Fatalf("TrackedFiles: %v", err)
	}
	if _, err := HeadSHA(work); err != nil {
		t.Fatalf("HeadSHA: %v", err)
	}
	if _, _, err := ChangesSince(work, ""); err != nil {
		t.Fatalf("ChangesSince empty: %v", err)
	}
	if _, _, err := ChangesSince(work, beforeSHA); err != nil {
		t.Fatalf("ChangesSince sha: %v", err)
	}
	if _, err := RemoteTags(work, ""); err != nil {
		t.Fatalf("RemoteTags default: %v", err)
	}
	if _, err := RemoteTags(work, bare); err != nil {
		t.Fatalf("RemoteTags explicit: %v", err)
	}
	if _, err := LatestSemverTag(work); err != nil {
		t.Fatalf("LatestSemverTag: %v", err)
	}
	if _, err := SemverTags(work); err != nil {
		t.Fatalf("SemverTags: %v", err)
	}
	if _, _, err := LastCommitDate(work, "base.txt"); err != nil {
		t.Fatalf("LastCommitDate: %v", err)
	}

	after := fingerprint(t, work)
	if before != after {
		t.Errorf("read-only invariant violated: repo state changed across gitx calls\nbefore:\n%q\nafter:\n%q", before, after)
	}
}