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

@@ -40,13 +40,13 @@ these implemented packages:
| `internal/build` | Candidate-build coordination, selected Go target compilation, version injection, native smoke check, and artifact digests. |
| `internal/candidate` | Stable-version, note, repository-state, candidate capture, and post-validation checks. |
| `internal/delivery` | Tagged-source and release-note checks, managed builds in disposable storage outside the checkout, prebuilt artifact validation, manifest assembly, and hosted delivery coordination. |
| `internal/git` | Git command boundary, push-destination resolution, exact ref inspection, tagged checkout preparation, and tag operations, including captured-endpoint verification during recovery. |
| `internal/git` | Git command boundary, committed-tree and blob reads, push-destination resolution, exact ref inspection, tagged checkout preparation, and tag operations, including captured-endpoint verification during recovery. |
| `internal/gitea` | Gitea API transport, hosted release pages, asset inspection, manifests, and uploads. |
| `internal/gotool` | Effective Go configuration discovery, shared native-validation and static-build environments, and network-capable dependency preparation; see [Go command environments](gotool.md). |
| `internal/tempdir` | Disposable system temporary storage outside source worktrees. |
| `internal/process` | Cancellable child-process execution with per-command environment overlays and removals. |
| `internal/preparation` | Coordination for expected-commit tag checkout and Go module dependency preparation. |
| `internal/validation` | Shared default Go validation and structured check failures for captured candidates; `internal/candidate` retains capture, callback sequencing, and local-state rechecks. |
| `internal/validation` | Shared default Go validation, structured check failures, and lazy committed-file access for captured candidates; `internal/candidate` retains capture, callback sequencing, and local-state rechecks. |
| `internal/publish` | Publication guards, one validation run, annotated tag creation, push, and recovery state. |
| `internal/runtimeinfo` | Private version lookup used by the runtime facade. |
| `internal/versioncontract` | Shared stable-version rules and linker-symbol identity. |

View File

@@ -1,6 +1,6 @@
# Validation model implementation plan
Status: In progress; the shared validation executor and internal diagnostics are complete.
Status: In progress; the shared executor, internal diagnostics, and committed-file reader are complete.
This plan implements the enabling work in [model.md](model.md), guided by
[ADR 0003](../adr/0003-declarative-validation.md). It is written for a Luna
@@ -229,6 +229,10 @@ complete error text.
## Stage 3: Add committed candidate-file enumeration
**Completion:** Complete. `internal/git` enumerates committed tree entries and
blob sizes, while `internal/validation` exposes a lazy candidate-tree reader
that serves blob bytes by object ID.
**Outcome:** File-oriented validators can share an offline reader bound to the
captured commit.

View File

@@ -1,12 +1,26 @@
package git
import (
"bytes"
"context"
"fmt"
"path"
"strconv"
"strings"
"gitea.maximumdirect.net/eric/ReleaseKit/internal/process"
)
// TreeEntry records one committed path and its Git object metadata.
type TreeEntry struct {
Path string
ObjectID string
Mode string
Type string
Size int64
HasSize bool
}
type TaggedSource struct {
Head string
Status string
@@ -74,3 +88,112 @@ func ReadCommitFile(ctx context.Context, repositoryDir, commit, name string) ([]
}
return []byte(content), nil
}
// ReadTree enumerates all entries in a commit and records blob sizes. Paths
// are NUL-delimited by Git so whitespace in a filename is preserved.
func ReadTree(ctx context.Context, repositoryDir, commit string) ([]TreeEntry, error) {
if !validObjectID(commit) {
return nil, fmt.Errorf("invalid commit object ID %q", commit)
}
output, err := run(ctx, repositoryDir, nil, "ls-tree", "-r", "-z", "--full-tree", commit)
if err != nil {
return nil, fmt.Errorf("enumerate committed tree %s: %w", commit, err)
}
entries, err := parseTreeEntries([]byte(output))
if err != nil {
return nil, fmt.Errorf("parse committed tree %s: %w", commit, err)
}
if err := populateBlobSizes(ctx, repositoryDir, entries); err != nil {
return nil, fmt.Errorf("read committed blob sizes for %s: %w", commit, err)
}
return entries, nil
}
// ReadBlob reads the bytes for a Git blob object ID without consulting the
// worktree.
func ReadBlob(ctx context.Context, repositoryDir, objectID string) ([]byte, error) {
if !validObjectID(objectID) {
return nil, fmt.Errorf("invalid blob object ID %q", objectID)
}
content, err := run(ctx, repositoryDir, nil, "cat-file", "blob", objectID)
if err != nil {
return nil, fmt.Errorf("read blob %s: %w", objectID, err)
}
return []byte(content), nil
}
func parseTreeEntries(output []byte) ([]TreeEntry, error) {
if len(output) == 0 {
return nil, nil
}
var entries []TreeEntry
for _, record := range bytes.Split(output, []byte{0}) {
if len(record) == 0 {
continue
}
tab := bytes.IndexByte(record, '\t')
if tab < 0 {
return nil, fmt.Errorf("tree record has no path separator")
}
fields := strings.Fields(string(record[:tab]))
if len(fields) != 3 || !validObjectID(fields[2]) {
return nil, fmt.Errorf("invalid tree record metadata %q", record[:tab])
}
entries = append(entries, TreeEntry{
Mode: fields[0],
Type: fields[1],
ObjectID: fields[2],
Path: string(record[tab+1:]),
})
}
return entries, nil
}
func populateBlobSizes(ctx context.Context, repositoryDir string, entries []TreeEntry) error {
var blobIndexes []int
var input strings.Builder
for index := range entries {
if entries[index].Type != "blob" {
continue
}
blobIndexes = append(blobIndexes, index)
input.WriteString(entries[index].ObjectID)
input.WriteByte('\n')
}
if len(blobIndexes) == 0 {
return nil
}
var stdout, stderr bytes.Buffer
err := process.Run(ctx, process.Invocation{
Args: []string{"git", "cat-file", "--batch-check=%(objectname) %(objecttype) %(objectsize)"},
Dir: repositoryDir,
Stdin: strings.NewReader(input.String()),
Stdout: &stdout,
Stderr: &stderr,
})
if err != nil {
message := strings.TrimSpace(stderr.String())
if message != "" {
return fmt.Errorf("git cat-file --batch-check: %s: %w", message, err)
}
return err
}
lines := strings.Split(strings.TrimSuffix(stdout.String(), "\n"), "\n")
if len(lines) != len(blobIndexes) {
return fmt.Errorf("git returned %d blob sizes for %d blobs", len(lines), len(blobIndexes))
}
for index, line := range lines {
fields := strings.Fields(line)
entry := &entries[blobIndexes[index]]
if len(fields) != 3 || fields[0] != entry.ObjectID || fields[1] != "blob" {
return fmt.Errorf("unexpected blob size record %q", line)
}
size, err := strconv.ParseInt(fields[2], 10, 64)
if err != nil || size < 0 {
return fmt.Errorf("invalid blob size in record %q", line)
}
entry.Size = size
entry.HasSize = true
}
return nil
}

View File

@@ -19,6 +19,7 @@ type Invocation struct {
Dir string
Env map[string]string
UnsetEnv []string
Stdin io.Reader
Stdout io.Writer
Stderr io.Writer
}
@@ -32,6 +33,7 @@ func Run(ctx context.Context, invocation Invocation) error {
command := exec.CommandContext(ctx, invocation.Args[0], invocation.Args[1:]...)
command.Dir = invocation.Dir
command.Stdin = invocation.Stdin
if len(invocation.Env) != 0 || len(invocation.UnsetEnv) != 0 {
command.Env = environmentEntries(invocation.Env, invocation.UnsetEnv)
}

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)