Read files from captured Git commits

This commit is contained in:
2026-10-07 18:29:34 +00:00
parent 136a97fb6c
commit 1cad13649e
7 changed files with 335 additions and 3 deletions

View File

@@ -0,0 +1,72 @@
package validation
import (
"context"
"fmt"
"gitea.maximumdirect.net/eric/ReleaseKit/internal/git"
)
// CandidateTree is an inventory of one captured commit with blob contents
// loaded lazily by object ID.
type CandidateTree struct {
repositoryDir string
entries []git.TreeEntry
byPath map[string]int
blobs map[string][]byte
}
// ReadCandidateTree enumerates the captured commit without reading from the
// worktree.
func ReadCandidateTree(ctx context.Context, repositoryDir, commit string) (*CandidateTree, error) {
entries, err := git.ReadTree(ctx, repositoryDir, commit)
if err != nil {
return nil, err
}
tree := &CandidateTree{
repositoryDir: repositoryDir,
entries: entries,
byPath: make(map[string]int, len(entries)),
blobs: make(map[string][]byte),
}
for index, entry := range entries {
tree.byPath[entry.Path] = index
}
return tree, nil
}
// Entries returns the committed tree metadata in Git's tree order.
func (tree *CandidateTree) Entries() []git.TreeEntry {
return append([]git.TreeEntry(nil), tree.entries...)
}
// ReadFile returns the committed blob bytes for path. Contents are cached by
// object ID for later reads from this tree.
func (tree *CandidateTree) ReadFile(ctx context.Context, path string) ([]byte, error) {
index, ok := tree.byPath[path]
if !ok {
return nil, fmt.Errorf("committed path %q does not exist", path)
}
entry := tree.entries[index]
if entry.Type != "blob" {
return nil, fmt.Errorf("committed path %q is a %s entry, not a blob", path, entry.Type)
}
if contents, ok := tree.blobs[entry.ObjectID]; ok {
return append([]byte(nil), contents...), nil
}
contents, err := git.ReadBlob(ctx, tree.repositoryDir, entry.ObjectID)
if err != nil {
return nil, fmt.Errorf("read committed path %q: %w", path, err)
}
tree.blobs[entry.ObjectID] = append([]byte(nil), contents...)
return contents, nil
}
// Entry returns the committed metadata for path when present.
func (tree *CandidateTree) Entry(path string) (git.TreeEntry, bool) {
index, ok := tree.byPath[path]
if !ok {
return git.TreeEntry{}, false
}
return tree.entries[index], true
}

View File

@@ -0,0 +1,125 @@
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)
}
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 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.T) 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.T, 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.T, 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)
}

View File

@@ -20,6 +20,12 @@ type Request struct {
Settings Settings
}
// ReadCandidateTree loads the committed-file inventory when a selected check
// needs file-oriented input.
func (request Request) ReadCandidateTree(ctx context.Context) (*CandidateTree, error) {
return ReadCandidateTree(ctx, request.RepositoryDir, request.Commit)
}
// Run executes shared validation for the captured candidate.
func Run(ctx context.Context, request Request) error {
tool, err := gotool.New(ctx, request.RepositoryDir)