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

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