diff --git a/docs/internal/overview.md b/docs/internal/overview.md index bfbbb2b..4f2ed8b 100644 --- a/docs/internal/overview.md +++ b/docs/internal/overview.md @@ -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. | diff --git a/docs/roadmap/implementation.md b/docs/roadmap/implementation.md index b7ae2cf..e290355 100644 --- a/docs/roadmap/implementation.md +++ b/docs/roadmap/implementation.md @@ -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. diff --git a/internal/git/source.go b/internal/git/source.go index 7af7a33..00a4d45 100644 --- a/internal/git/source.go +++ b/internal/git/source.go @@ -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 +} diff --git a/internal/process/process.go b/internal/process/process.go index 8e53072..2e57a66 100644 --- a/internal/process/process.go +++ b/internal/process/process.go @@ -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) } diff --git a/internal/validation/tree.go b/internal/validation/tree.go new file mode 100644 index 0000000..c45ee4b --- /dev/null +++ b/internal/validation/tree.go @@ -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 +} diff --git a/internal/validation/tree_test.go b/internal/validation/tree_test.go new file mode 100644 index 0000000..8cb9bf4 --- /dev/null +++ b/internal/validation/tree_test.go @@ -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) +} diff --git a/internal/validation/validation.go b/internal/validation/validation.go index 890ca70..9424d26 100644 --- a/internal/validation/validation.go +++ b/internal/validation/validation.go @@ -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)