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