385 lines
15 KiB
Go
385 lines
15 KiB
Go
package validation
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"errors"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
func TestReadCandidateTreeUsesCapturedCommitAndEnumeratesGitEntries(t *testing.T) {
|
|
repository := newTreeRepository(t)
|
|
writeTreeFile(t, repository, "README.md", []byte("captured\n"))
|
|
writeTreeFile(t, repository, "vendor/library/go.mod", []byte("module vendor.example\n"))
|
|
writeTreeFile(t, repository, "nested/module/go.mod", []byte("module nested.example\n"))
|
|
writeTreeFile(t, repository, "binary.dat", []byte{0, 1, 255, 2})
|
|
unusualPath := "space tab\tline\nname.txt"
|
|
writeTreeFile(t, repository, unusualPath, []byte("unusual path\n"))
|
|
if err := os.Symlink("README.md", filepath.Join(repository, "readme-link")); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
submoduleCommit := newTreeRepository(t)
|
|
writeTreeFile(t, submoduleCommit, "sub.txt", []byte("submodule\n"))
|
|
gitTree(t, submoduleCommit, "add", "--all")
|
|
gitTree(t, submoduleCommit, "commit", "-m", "create submodule source")
|
|
submoduleOID := strings.TrimSpace(gitTree(t, submoduleCommit, "rev-parse", "HEAD"))
|
|
gitTree(t, repository, "add", "--all")
|
|
gitTree(t, repository, "update-index", "--add", "--cacheinfo", "160000,"+submoduleOID+",submodule")
|
|
gitTree(t, repository, "commit", "-m", "capture candidate files")
|
|
commit := strings.TrimSpace(gitTree(t, repository, "rev-parse", "HEAD"))
|
|
|
|
writeTreeFile(t, repository, "README.md", []byte("worktree changed\n"))
|
|
statusBefore := gitTree(t, repository, "status", "--porcelain=v1", "--untracked-files=all")
|
|
tree, err := ReadCandidateTree(context.Background(), repository, commit)
|
|
if err != nil {
|
|
t.Fatalf("ReadCandidateTree() error = %v", err)
|
|
}
|
|
cleanupCandidateTree(t, tree)
|
|
entries := make(map[string]gitTreeEntry, len(tree.Entries()))
|
|
for _, entry := range tree.Entries() {
|
|
entries[entry.Path] = gitTreeEntry{mode: entry.Mode, kind: entry.Type, size: entry.Size, hasSize: entry.HasSize}
|
|
}
|
|
for _, path := range []string{"vendor/library/go.mod", "nested/module/go.mod", unusualPath} {
|
|
if _, ok := entries[path]; !ok {
|
|
t.Errorf("committed tree is missing %q", path)
|
|
}
|
|
}
|
|
if got := entries["binary.dat"]; got.size != 4 || !got.hasSize {
|
|
t.Errorf("binary metadata = %#v, want size 4", got)
|
|
}
|
|
if got := entries["readme-link"]; got.mode != "120000" || got.kind != "blob" || !got.hasSize {
|
|
t.Errorf("symlink metadata = %#v", got)
|
|
}
|
|
if got := entries["submodule"]; got.mode != "160000" || got.kind != "commit" || got.hasSize {
|
|
t.Errorf("gitlink metadata = %#v", got)
|
|
}
|
|
contents, err := tree.ReadFile(context.Background(), "README.md")
|
|
if err != nil || string(contents) != "captured\n" {
|
|
t.Fatalf("captured README = %q, error = %v", contents, err)
|
|
}
|
|
binary, err := tree.ReadFile(context.Background(), "binary.dat")
|
|
if err != nil || !bytes.Equal(binary, []byte{0, 1, 255, 2}) {
|
|
t.Fatalf("captured binary = %v, error = %v", binary, err)
|
|
}
|
|
unusual, err := tree.ReadFile(context.Background(), unusualPath)
|
|
if err != nil || string(unusual) != "unusual path\n" {
|
|
t.Fatalf("unusual path contents = %q, error = %v", unusual, err)
|
|
}
|
|
if _, err := tree.ReadFile(context.Background(), "submodule"); err == nil {
|
|
t.Fatal("ReadFile() returned submodule commit contents")
|
|
}
|
|
statusAfter := gitTree(t, repository, "status", "--porcelain=v1", "--untracked-files=all")
|
|
if statusAfter != statusBefore {
|
|
t.Fatalf("tree inspection changed worktree state: before %q, after %q", statusBefore, statusAfter)
|
|
}
|
|
}
|
|
|
|
func TestReadCandidateTreeForScopeExcludesNestedModulesAndRetainsFullLookup(t *testing.T) {
|
|
repository := newTreeRepository(t)
|
|
writeTreeFile(t, repository, "COPYRIGHT", []byte("copyright\n"))
|
|
writeTreeFile(t, repository, "LICENSE", []byte("license\n"))
|
|
writeTreeFile(t, repository, "docs/oss/attribution.md", []byte("attribution\n"))
|
|
writeTreeFile(t, repository, "go.mod", []byte("module example.com/root\n"))
|
|
writeTreeFile(t, repository, "root.go", []byte("package root\n"))
|
|
writeTreeFile(t, repository, "parent/go.mod", []byte("module example.com/parent\n"))
|
|
writeTreeFile(t, repository, "parent/main.go", []byte("package parent\n"))
|
|
writeTreeFile(t, repository, "parent/vendor/library/go.mod", []byte("not a boundary\n"))
|
|
writeTreeFile(t, repository, "parent/vendor/library/source.go", []byte("package library\n"))
|
|
writeTreeFile(t, repository, "parent/nested/go.mod", []byte("module example.com/nested\n"))
|
|
writeTreeFile(t, repository, "parent/nested/source.go", []byte("package nested\n"))
|
|
writeTreeFile(t, repository, "sibling/go.mod", []byte("module example.com/sibling\n"))
|
|
writeTreeFile(t, repository, "sibling/source.go", []byte("package sibling\n"))
|
|
commit := commitCandidateTree(t, repository)
|
|
writeTreeFile(t, repository, "parent/worktree-only.go", []byte("package parent\n"))
|
|
|
|
tree, err := ReadCandidateTreeForScope(context.Background(), repository, commit, TreeScope{
|
|
ModuleDir: filepath.Join(repository, "parent"),
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
cleanupCandidateTree(t, tree)
|
|
var got []string
|
|
for _, entry := range tree.InputEntries() {
|
|
got = append(got, entry.Path)
|
|
}
|
|
want := []string{
|
|
"COPYRIGHT", "LICENSE", "docs/oss/attribution.md", "parent/go.mod", "parent/main.go",
|
|
"parent/vendor/library/go.mod", "parent/vendor/library/source.go",
|
|
}
|
|
if strings.Join(got, "\n") != strings.Join(want, "\n") {
|
|
t.Fatalf("selected inputs = %q, want %q", got, want)
|
|
}
|
|
if _, ok := tree.Entry("parent/nested/source.go"); !ok {
|
|
t.Fatal("full tree lookup lost an excluded nested module destination")
|
|
}
|
|
destination, err := tree.ResolveDestination(context.Background(), "parent/main.go", "../sibling/source.go")
|
|
if err != nil || destination.Kind != DestinationEntry || destination.Path != "sibling/source.go" {
|
|
t.Fatalf("excluded sibling destination = %#v, %v", destination, err)
|
|
}
|
|
nestedDestination, err := tree.ResolveDestination(context.Background(), "parent/main.go", "nested/source.go")
|
|
if err != nil || nestedDestination.Kind != DestinationEntry || nestedDestination.Path != "parent/nested/source.go" {
|
|
t.Fatalf("excluded nested-module destination = %#v, %v", nestedDestination, err)
|
|
}
|
|
if tree.blobReader != nil || tree.blobReads != 0 {
|
|
t.Fatalf("metadata-only selection read blob content: reader=%v reads=%d", tree.blobReader != nil, tree.blobReads)
|
|
}
|
|
}
|
|
|
|
func TestReadCandidateTreeForRootScopeExcludesNestedModules(t *testing.T) {
|
|
repository := newTreeRepository(t)
|
|
writeTreeFile(t, repository, "go.mod", []byte("module example.com/root\n"))
|
|
writeTreeFile(t, repository, "root.go", []byte("package root\n"))
|
|
writeTreeFile(t, repository, "nested/go.mod", []byte("module example.com/nested\n"))
|
|
writeTreeFile(t, repository, "nested/source.go", []byte("package nested\n"))
|
|
writeTreeFile(t, repository, "vendor/library/go.mod", []byte("not a boundary\n"))
|
|
writeTreeFile(t, repository, "vendor/library/source.go", []byte("package library\n"))
|
|
commit := commitCandidateTree(t, repository)
|
|
|
|
tree, err := ReadCandidateTreeForScope(context.Background(), repository, commit, TreeScope{ModuleDir: repository})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
cleanupCandidateTree(t, tree)
|
|
want := []string{"go.mod", "root.go", "vendor/library/go.mod", "vendor/library/source.go"}
|
|
if got := inputPaths(tree); strings.Join(got, "\n") != strings.Join(want, "\n") {
|
|
t.Fatalf("root module inputs = %q, want %q", got, want)
|
|
}
|
|
}
|
|
|
|
func TestReadCandidateTreeSharedFilesReplaceDefaultsAndDeduplicate(t *testing.T) {
|
|
repository := newTreeRepository(t)
|
|
writeTreeFile(t, repository, "LICENSE", []byte("default\n"))
|
|
writeTreeFile(t, repository, "module/go.mod", []byte("module example.com/module\n"))
|
|
writeTreeFile(t, repository, "module/source.go", []byte("package module\n"))
|
|
writeTreeFile(t, repository, "sibling/shared.txt", []byte("selected explicitly\n"))
|
|
writeTreeFile(t, repository, "literal/*.md", []byte("literal wildcard path\n"))
|
|
commit := commitCandidateTree(t, repository)
|
|
moduleDir := filepath.Join(repository, "module")
|
|
|
|
selected, err := ReadCandidateTreeForScope(context.Background(), repository, commit, TreeScope{
|
|
ModuleDir: moduleDir,
|
|
SharedFiles: []string{"sibling/shared.txt", "module/source.go", "sibling/shared.txt"},
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
cleanupCandidateTree(t, selected)
|
|
var got []string
|
|
for _, entry := range selected.InputEntries() {
|
|
got = append(got, entry.Path)
|
|
}
|
|
want := []string{"module/go.mod", "module/source.go", "sibling/shared.txt"}
|
|
if strings.Join(got, "\n") != strings.Join(want, "\n") {
|
|
t.Fatalf("explicit shared inputs = %q, want %q", got, want)
|
|
}
|
|
|
|
empty, err := ReadCandidateTreeForScope(context.Background(), repository, commit, TreeScope{
|
|
ModuleDir: moduleDir,
|
|
SharedFiles: []string{},
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
cleanupCandidateTree(t, empty)
|
|
if got := inputPaths(empty); strings.Join(got, "\n") != "module/go.mod\nmodule/source.go" {
|
|
t.Fatalf("explicit empty input set = %q", got)
|
|
}
|
|
|
|
literal, err := ReadCandidateTreeForScope(context.Background(), repository, commit, TreeScope{
|
|
ModuleDir: moduleDir,
|
|
SharedFiles: []string{"literal/*.md"},
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
cleanupCandidateTree(t, literal)
|
|
if got := inputPaths(literal); strings.Join(got, "\n") != "literal/*.md\nmodule/go.mod\nmodule/source.go" {
|
|
t.Fatalf("literal wildcard path inputs = %q", got)
|
|
}
|
|
}
|
|
|
|
func TestReadCandidateTreeDefaultsAreOptionalAndExplicitMissingFilesFail(t *testing.T) {
|
|
repository := newTreeRepository(t)
|
|
writeTreeFile(t, repository, "module/go.mod", []byte("module example.com/module\n"))
|
|
writeTreeFile(t, repository, "module/source.go", []byte("package module\n"))
|
|
writeTreeFile(t, repository, "LICENSE", []byte("license\n"))
|
|
commit := commitCandidateTree(t, repository)
|
|
moduleDir := filepath.Join(repository, "module")
|
|
|
|
defaults, err := ReadCandidateTreeForScope(context.Background(), repository, commit, TreeScope{ModuleDir: moduleDir})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
cleanupCandidateTree(t, defaults)
|
|
if got := inputPaths(defaults); strings.Join(got, "\n") != "LICENSE\nmodule/go.mod\nmodule/source.go" {
|
|
t.Fatalf("default shared inputs = %q", got)
|
|
}
|
|
|
|
writeTreeFile(t, repository, "missing.md", []byte("present only in the worktree\n"))
|
|
_, err = ReadCandidateTreeForScope(context.Background(), repository, commit, TreeScope{
|
|
ModuleDir: moduleDir,
|
|
SharedFiles: []string{"missing.md"},
|
|
})
|
|
var failure *Failure
|
|
if !errors.As(err, &failure) || failure.Check != SharedFiles || len(failure.Findings) != 1 || failure.Findings[0].Path != "missing.md" {
|
|
t.Fatalf("missing explicit shared file error = %#v, want path-specific shared-files failure", err)
|
|
}
|
|
}
|
|
|
|
func TestRunBuildsInputsOnlyForChecksOrExplicitSharedFiles(t *testing.T) {
|
|
repository := newTreeRepository(t)
|
|
writeTreeFile(t, repository, "go.mod", []byte("module example.com/validation-tree\n\ngo 1.25.0\n"))
|
|
writeTreeFile(t, repository, "main.go", []byte("package validationtree\n"))
|
|
commit := commitCandidateTree(t, repository)
|
|
gitPath, err := exec.LookPath("git")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
bin := filepath.Join(t.TempDir(), "bin")
|
|
if err := os.MkdirAll(bin, 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
logPath := filepath.Join(t.TempDir(), "tree-reads")
|
|
wrapper := "#!/bin/sh\nREAL_GIT='" + gitPath + "'\nLOG='" + logPath + "'\nfor ARG in \"$@\"; do [ \"$ARG\" = ls-tree ] && echo read >> \"$LOG\"; done\nexec \"$REAL_GIT\" \"$@\"\n"
|
|
if err := os.WriteFile(filepath.Join(bin, "git"), []byte(wrapper), 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
t.Setenv("PATH", bin+string(os.PathListSeparator)+os.Getenv("PATH"))
|
|
|
|
if err := Run(context.Background(), Request{RepositoryDir: repository, Commit: commit}); err != nil {
|
|
t.Fatalf("metadata-only Run(): %v", err)
|
|
}
|
|
if _, err := os.Stat(logPath); !errors.Is(err, os.ErrNotExist) {
|
|
t.Fatalf("default-only request constructed file inputs: stat error=%v", err)
|
|
}
|
|
|
|
err = Run(context.Background(), Request{
|
|
RepositoryDir: repository,
|
|
Commit: commit,
|
|
Settings: Settings{SharedFiles: []string{"missing.txt"}},
|
|
})
|
|
var failure *Failure
|
|
if !errors.As(err, &failure) || failure.Check != SharedFiles || len(failure.Findings) != 1 || failure.Findings[0].Path != "missing.txt" {
|
|
t.Fatalf("explicit missing shared file Run() error = %#v", err)
|
|
}
|
|
if data, err := os.ReadFile(logPath); err != nil || len(data) == 0 {
|
|
t.Fatalf("explicit shared-file request did not construct committed file inputs: %q, %v", data, err)
|
|
}
|
|
}
|
|
|
|
func inputPaths(tree *CandidateTree) []string {
|
|
entries := tree.InputEntries()
|
|
paths := make([]string, 0, len(entries))
|
|
for _, entry := range entries {
|
|
paths = append(paths, entry.Path)
|
|
}
|
|
return paths
|
|
}
|
|
|
|
func TestReadFileReturnsOwnedBytesAndCachesOnlySymlinkTargets(t *testing.T) {
|
|
repository := newTreeRepository(t)
|
|
writeTreeFile(t, repository, "source.txt", []byte("committed content"))
|
|
if err := os.Symlink("source.txt", filepath.Join(repository, "alias.txt")); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
commit := commitCandidateTree(t, repository)
|
|
tree, err := ReadCandidateTree(context.Background(), repository, commit)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
cleanupCandidateTree(t, tree)
|
|
|
|
first, err := tree.ReadFile(context.Background(), "source.txt")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
first[0] = 'X'
|
|
second, err := tree.ReadFile(context.Background(), "source.txt")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if string(second) != "committed content" {
|
|
t.Fatalf("second regular-file read = %q, want committed content", second)
|
|
}
|
|
if &first[0] == &second[0] {
|
|
t.Fatal("regular-file reads share a backing byte slice")
|
|
}
|
|
|
|
linkFirst, err := tree.ReadFile(context.Background(), "alias.txt")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
linkFirst[0] = 'X'
|
|
linkSecond, err := tree.ReadFile(context.Background(), "alias.txt")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if string(linkSecond) != "source.txt" {
|
|
t.Fatalf("second symlink read = %q, want source.txt", linkSecond)
|
|
}
|
|
if len(tree.symlinkTargets) != 1 {
|
|
t.Fatalf("cached symlink target count = %d, want 1", len(tree.symlinkTargets))
|
|
}
|
|
destination, err := tree.ResolveDestination(context.Background(), "README.md", "alias.txt")
|
|
if err != nil || destination.Kind != DestinationEntry || destination.Path != "source.txt" {
|
|
t.Fatalf("ResolveDestination() = %#v, %v; want source.txt entry", destination, err)
|
|
}
|
|
}
|
|
|
|
func cleanupCandidateTree(tb testing.TB, tree *CandidateTree) {
|
|
tb.Helper()
|
|
tb.Cleanup(func() { _ = tree.Close() })
|
|
}
|
|
|
|
func TestReadCandidateTreePreservesGitProcessError(t *testing.T) {
|
|
repository := newTreeRepository(t)
|
|
_, err := ReadCandidateTree(context.Background(), repository, strings.Repeat("f", 40))
|
|
var exitErr *exec.ExitError
|
|
if !errors.As(err, &exitErr) {
|
|
t.Fatalf("ReadCandidateTree() error = %v, want Git process error preserved", err)
|
|
}
|
|
}
|
|
|
|
type gitTreeEntry struct {
|
|
mode string
|
|
kind string
|
|
size int64
|
|
hasSize bool
|
|
}
|
|
|
|
func newTreeRepository(t testing.TB) string {
|
|
t.Helper()
|
|
repository := t.TempDir()
|
|
gitTree(t, repository, "init", "-b", "main")
|
|
gitTree(t, repository, "config", "user.name", "ReleaseKit Test")
|
|
gitTree(t, repository, "config", "user.email", "releasekit@example.invalid")
|
|
return repository
|
|
}
|
|
|
|
func writeTreeFile(t testing.TB, repository, name string, content []byte) {
|
|
t.Helper()
|
|
path := filepath.Join(repository, filepath.FromSlash(name))
|
|
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(path, content, 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
|
|
func gitTree(t testing.TB, repository string, args ...string) string {
|
|
t.Helper()
|
|
command := exec.Command("git", args...)
|
|
command.Dir = repository
|
|
output, err := command.CombinedOutput()
|
|
if err != nil {
|
|
t.Fatalf("git %s: %v\n%s", strings.Join(args, " "), err, output)
|
|
}
|
|
return string(output)
|
|
}
|