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