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)
}
}