Read files from captured Git commits
This commit is contained in:
@@ -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. |
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
72
internal/validation/tree.go
Normal file
72
internal/validation/tree.go
Normal 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
|
||||
}
|
||||
125
internal/validation/tree_test.go
Normal file
125
internal/validation/tree_test.go
Normal 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)
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user