Files

371 lines
11 KiB
Go

package gitutil
import (
"os"
"os/exec"
"path/filepath"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// requireGit skips the test when git is not on PATH so the suite stays green on
// hosts without git rather than failing for an environmental reason.
func requireGit(t *testing.T) {
t.Helper()
if _, err := exec.LookPath("git"); err != nil {
t.Skip("git not available")
}
}
// gitInit creates a quiet, identity-configured repo. Identity and gpgsign are set
// explicitly so commits succeed in CI where no global git config exists.
func gitInit(t *testing.T, root string) {
t.Helper()
for _, args := range [][]string{
// -b main pins the initial branch so tests that checkout "main" work
// regardless of the host git's init.defaultBranch (CI defaults to master).
{"init", "-q", "-b", "main"},
{"config", "user.email", "test@example.com"},
{"config", "user.name", "Test"},
{"config", "commit.gpgsign", "false"},
} {
cmd := exec.Command("git", append([]string{"-C", root}, args...)...)
out, err := cmd.CombinedOutput()
require.NoError(t, err, "git %v: %s", args, out)
}
}
// writeFile creates parent dirs and writes a file under root.
func writeFile(t *testing.T, root, rel, content string) {
t.Helper()
abs := filepath.Join(root, rel)
require.NoError(t, os.MkdirAll(filepath.Dir(abs), 0o755))
require.NoError(t, os.WriteFile(abs, []byte(content), 0o644))
}
// commitAt stages everything and creates a commit whose author and committer dates
// are pinned to year, making FirstCommitYear deterministic across runs and hosts.
func commitAt(t *testing.T, root, message, year string) {
t.Helper()
cmd := exec.Command("git", "-C", root, "add", "-A")
out, err := cmd.CombinedOutput()
require.NoError(t, err, "git add: %s", out)
date := year + "-06-15T12:00:00 +0000"
cmd = exec.Command("git", "-C", root, "commit", "-q", "-m", message)
cmd.Env = append(os.Environ(),
"GIT_AUTHOR_DATE="+date,
"GIT_COMMITTER_DATE="+date,
)
out, err = cmd.CombinedOutput()
require.NoError(t, err, "git commit: %s", out)
}
// newRepo returns a fresh temp dir initialized as a git repo.
func newRepo(t *testing.T) string {
t.Helper()
requireGit(t)
root := t.TempDir()
gitInit(t, root)
return root
}
// TestRunErrorWrapsGit drives the run helper's error branch through a public caller
// and asserts the wrapped error names the failing git subcommand.
func TestRunErrorWrapsGit(t *testing.T) {
requireGit(t)
dir := t.TempDir() // not a git repo
_, err := ListFiles(dir)
require.Error(t, err)
assert.Contains(t, err.Error(), "git ls-files",
"wrapped error should name the git subcommand that failed")
}
// TestIsRepo covers both branches: a real working tree reports true, a plain dir
// reports false because git rev-parse exits non-zero outside a repo.
func TestIsRepo(t *testing.T) {
requireGit(t)
repo := newRepo(t)
nonRepo := t.TempDir()
cases := []struct {
name string
path string
want bool
}{
{"inside work tree", repo, true},
{"plain directory", nonRepo, false},
{"nonexistent path", filepath.Join(nonRepo, "does-not-exist"), false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
assert.Equal(t, tc.want, IsRepo(tc.path))
})
}
}
// TestListFiles covers the tracked-file enumeration: a populated repo returns the
// sorted tracked set (and never lists .gitignore'd untracked files), while an empty
// repo returns a nil slice with no error.
func TestListFiles(t *testing.T) {
requireGit(t)
t.Run("tracked files only", func(t *testing.T) {
root := newRepo(t)
writeFile(t, root, "a.go", "package a\n")
writeFile(t, root, "sub/b.go", "package b\n")
writeFile(t, root, ".gitignore", "ignored.txt\n")
writeFile(t, root, "ignored.txt", "nope\n") // untracked + ignored
commitAt(t, root, "init", "2020")
files, err := ListFiles(root)
require.NoError(t, err)
assert.Equal(t, []string{".gitignore", "a.go", "sub/b.go"}, files)
assert.NotContains(t, files, "ignored.txt")
})
t.Run("empty repo returns nil", func(t *testing.T) {
root := newRepo(t) // initialized, nothing committed/added
files, err := ListFiles(root)
require.NoError(t, err)
assert.Nil(t, files)
})
t.Run("non-repo errors", func(t *testing.T) {
_, err := ListFiles(t.TempDir())
require.Error(t, err)
})
}
// TestIsClean covers all three outcomes: a committed tree is clean, an added-but-
// uncommitted file makes it dirty, and a non-repo surfaces the error path.
func TestIsClean(t *testing.T) {
requireGit(t)
t.Run("clean after commit", func(t *testing.T) {
root := newRepo(t)
writeFile(t, root, "a.go", "package a\n")
commitAt(t, root, "init", "2020")
clean, err := IsClean(root)
require.NoError(t, err)
assert.True(t, clean)
})
t.Run("dirty with uncommitted change", func(t *testing.T) {
root := newRepo(t)
writeFile(t, root, "a.go", "package a\n")
commitAt(t, root, "init", "2020")
writeFile(t, root, "b.go", "package b\n") // new untracked file -> dirty
clean, err := IsClean(root)
require.NoError(t, err)
assert.False(t, clean)
})
t.Run("non-repo errors", func(t *testing.T) {
_, err := IsClean(t.TempDir())
require.Error(t, err)
})
}
// TestFirstCommitYear covers the happy path (single pinned root commit), the
// earliest-of-multiple-roots selection across merged histories, the no-commits
// error, and the non-repo git error.
func TestFirstCommitYear(t *testing.T) {
requireGit(t)
t.Run("single root commit", func(t *testing.T) {
root := newRepo(t)
writeFile(t, root, "a.go", "package a\n")
commitAt(t, root, "init", "2018")
// A later commit must not change the first-commit year.
writeFile(t, root, "b.go", "package b\n")
commitAt(t, root, "second", "2022")
year, err := FirstCommitYear(root)
require.NoError(t, err)
assert.Equal(t, 2018, year)
})
t.Run("earliest of multiple roots", func(t *testing.T) {
root := newRepo(t)
writeFile(t, root, "a.go", "package a\n")
commitAt(t, root, "init", "2017")
// Create a second, independent root commit on an orphan branch with an
// earlier pinned year, then merge it so the repo has two root commits.
runGit(t, root, nil, "checkout", "-q", "--orphan", "other")
// orphan checkout leaves the index populated; clear it so we commit only new content.
runGit(t, root, nil, "rm", "-rf", "--quiet", ".")
writeFile(t, root, "c.go", "package c\n")
commitAt(t, root, "other-root", "2015")
runGit(t, root, nil, "checkout", "-q", "main")
// Merge unrelated histories so both roots are reachable from HEAD.
runGit(t, root, []string{
"GIT_AUTHOR_DATE=2023-06-15T12:00:00 +0000",
"GIT_COMMITTER_DATE=2023-06-15T12:00:00 +0000",
}, "merge", "-q", "--allow-unrelated-histories", "--no-edit", "other")
year, err := FirstCommitYear(root)
require.NoError(t, err)
assert.Equal(t, 2015, year, "should pick the earliest of the two root-commit years")
})
t.Run("no commits errors", func(t *testing.T) {
root := newRepo(t) // initialized but empty: git log exits non-zero
_, err := FirstCommitYear(root)
require.Error(t, err)
})
t.Run("non-repo errors", func(t *testing.T) {
_, err := FirstCommitYear(t.TempDir())
require.Error(t, err)
})
}
// TestFirstCommitYearDefensiveBranches exercises the defensive parse branches in
// FirstCommitYear that real `git log --date=format:%Y --format=%ad` output cannot
// reach (empty-but-successful output, a non-numeric line, and all-lines-unparseable).
// It substitutes the run seam to feed crafted git output, then restores the default.
func TestFirstCommitYearDefensiveBranches(t *testing.T) {
orig := run
t.Cleanup(func() { run = orig })
cases := []struct {
name string
out string
err error
wantYear int
wantError bool
}{
{"empty but successful output", "", nil, 0, true},
{"non-numeric lines only", "notayear\n----", nil, 0, true},
{"earliest skips unparseable lines", "garbage\n2014\n2019", nil, 2014, false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
run = func(path string, args ...string) (string, error) {
return tc.out, tc.err
}
year, err := FirstCommitYear("ignored")
if tc.wantError {
require.Error(t, err)
return
}
require.NoError(t, err)
assert.Equal(t, tc.wantYear, year)
})
}
}
// runGit is a test helper that runs git -C root with optional extra env and fails
// the test on a non-zero exit, surfacing combined output for diagnosis.
func runGit(t *testing.T, root string, extraEnv []string, args ...string) {
t.Helper()
cmd := exec.Command("git", append([]string{"-C", root}, args...)...)
if extraEnv != nil {
cmd.Env = append(os.Environ(), extraEnv...)
}
out, err := cmd.CombinedOutput()
require.NoError(t, err, "git %v: %s", args, out)
}
// TestCommit covers the success path (staged change becomes one commit), the
// nothing-to-commit error (git commit exits non-zero with an empty index), and the
// non-repo error from the initial add.
func TestCommit(t *testing.T) {
requireGit(t)
t.Run("stages and commits", func(t *testing.T) {
root := newRepo(t)
writeFile(t, root, "a.go", "package a\n")
// Make an initial commit so HEAD exists, then a tracked modification.
commitAt(t, root, "init", "2020")
writeFile(t, root, "a.go", "package a // edited\n")
err := Commit(root, "chore: license headers")
require.NoError(t, err)
clean, cerr := IsClean(root)
require.NoError(t, cerr)
assert.True(t, clean, "tree should be clean after Commit stages and commits all changes")
msg, gerr := run(root, "log", "-1", "--format=%s")
require.NoError(t, gerr)
assert.Equal(t, "chore: license headers", msg)
})
t.Run("nothing to commit errors", func(t *testing.T) {
root := newRepo(t)
writeFile(t, root, "a.go", "package a\n")
commitAt(t, root, "init", "2020")
// Tree is clean: add -A stages nothing, git commit exits non-zero.
err := Commit(root, "no-op commit")
require.Error(t, err)
})
t.Run("non-repo errors on add", func(t *testing.T) {
err := Commit(t.TempDir(), "msg")
require.Error(t, err)
})
}
func TestCommitPaths(t *testing.T) {
requireGit(t)
t.Run("commits only named paths", func(t *testing.T) {
root := newRepo(t)
writeFile(t, root, "a.go", "package a\n")
writeFile(t, root, "b.go", "package b\n")
commitAt(t, root, "init", "2020")
writeFile(t, root, "a.go", "package a\n\nvar Changed = true\n")
writeFile(t, root, "b.go", "package b\n\nvar Dirty = true\n")
runGit(t, root, nil, "add", "b.go")
err := CommitPaths(root, "chore: scoped", []string{"a.go"})
require.NoError(t, err)
msg, gerr := run(root, "log", "-1", "--format=%s")
require.NoError(t, gerr)
assert.Equal(t, "chore: scoped", msg)
changed, gerr := run(root, "show", "--name-only", "--format=", "HEAD")
require.NoError(t, gerr)
assert.Equal(t, "a.go", changed)
status, gerr := run(root, "status", "--porcelain")
require.NoError(t, gerr)
assert.Contains(t, status, "b.go")
})
t.Run("empty pathset errors before commit", func(t *testing.T) {
root := newRepo(t)
err := CommitPaths(root, "chore: empty", nil)
require.Error(t, err)
assert.Contains(t, err.Error(), "no paths to commit")
})
t.Run("non-repo errors on add", func(t *testing.T) {
err := CommitPaths(t.TempDir(), "chore: scoped", []string{"a.go"})
require.Error(t, err)
})
t.Run("nothing to commit errors after add", func(t *testing.T) {
root := newRepo(t)
writeFile(t, root, "a.go", "package a\n")
commitAt(t, root, "init", "2020")
err := CommitPaths(root, "chore: no-op", []string{"a.go"})
require.Error(t, err)
})
}