Files
releasekit/internal/git/source.go

427 lines
14 KiB
Go

package git
import (
"bytes"
"context"
"errors"
"fmt"
"io"
"os"
"os/exec"
"path"
"path/filepath"
"strconv"
"strings"
"go.maximumdirect.net/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
TagObject string
TagCommit string
TagObjectType string
}
// RepositoryRoot resolves the containing checkout, including independent
// worktrees and directories reached through filesystem aliases.
func RepositoryRoot(ctx context.Context, directory string) (string, error) {
if directory == "" {
directory = "."
}
root, err := run(ctx, directory, nil, "rev-parse", "--show-toplevel")
if err != nil {
return "", fmt.Errorf("resolve Git repository root: %w", err)
}
return normalizeRepositoryRoot(root)
}
func normalizeRepositoryRoot(root string) (string, error) {
root = strings.TrimSuffix(root, "\n")
resolved, err := filepath.EvalSymlinks(root)
if err != nil {
return "", fmt.Errorf("resolve Git repository directory: %w", err)
}
return resolved, nil
}
// DiscoverRepositoryRoot finds a containing checkout or confirms standalone use.
func DiscoverRepositoryRoot(ctx context.Context, directory string) (string, bool, error) {
if directory == "" {
directory = "."
}
var stdout, stderr strings.Builder
err := process.Run(ctx, process.Invocation{
Args: []string{"git", "rev-parse", "--show-toplevel"}, Dir: directory,
Env: map[string]string{"LC_ALL": "C"}, Stdout: &stdout, Stderr: &stderr,
})
if err == nil {
root, err := normalizeRepositoryRoot(stdout.String())
return root, err == nil, err
}
if errors.Is(err, exec.ErrNotFound) {
standalone, inspectErr := standaloneWithoutGit(directory)
if inspectErr != nil {
return "", false, inspectErr
}
if standalone {
return "", false, nil
}
}
var exitErr *exec.ExitError
if errors.As(err, &exitErr) && exitErr.ExitCode() == 128 {
if boundary, noRepository := noRepositoryBoundary(stderr.String()); noRepository {
boundaries := discoveryCeilings()
if boundary != "" {
boundaries = append(boundaries, boundary)
}
standalone, inspectErr := standaloneDirectory(directory, boundaries)
if inspectErr != nil {
return "", false, errors.Join(inspectErr, err)
}
if standalone {
return "", false, nil
}
}
}
if message := strings.TrimSpace(stderr.String()); message != "" {
return "", false, fmt.Errorf("resolve Git repository root: %s: %w", message, err)
}
return "", false, fmt.Errorf("resolve Git repository root: %w", err)
}
func noRepositoryBoundary(message string) (string, bool) {
message = strings.TrimSpace(message)
if message == "fatal: not a git repository (or any of the parent directories): .git" {
return "", true
}
const prefix = "fatal: not a git repository (or any parent up to mount point "
const suffix = ")\nStopping at filesystem boundary (GIT_DISCOVERY_ACROSS_FILESYSTEM not set)."
if strings.HasPrefix(message, prefix) && strings.HasSuffix(message, suffix) && len(message) > len(prefix)+len(suffix) {
return filepath.Clean(message[len(prefix) : len(message)-len(suffix)]), true
}
return "", false
}
func discoveryCeilings() []string {
var ceilings []string
resolveLinks := true
for _, entry := range filepath.SplitList(os.Getenv("GIT_CEILING_DIRECTORIES")) {
// Git uses an empty entry to mark subsequent paths as already physical.
if entry == "" {
resolveLinks = false
continue
}
if !filepath.IsAbs(entry) {
continue
}
if resolveLinks {
resolved, err := filepath.EvalSymlinks(entry)
if err != nil {
continue // Git ignores ceiling entries it cannot resolve.
}
entry = resolved
}
ceilings = append(ceilings, filepath.Clean(entry))
}
return ceilings
}
func standaloneWithoutGit(directory string) (bool, error) {
_, gitDirSet := os.LookupEnv("GIT_DIR")
_, workTreeSet := os.LookupEnv("GIT_WORK_TREE")
if gitDirSet || workTreeSet {
return false, nil
}
return standaloneDirectory(directory, nil)
}
// standaloneDirectory checks only locations Git could have searched. Boundaries
// are excluded parents; the supplied directory itself is always inspected.
func standaloneDirectory(directory string, boundaries []string) (bool, error) {
abs, err := filepath.Abs(directory)
if err != nil {
return false, fmt.Errorf("inspect repository directory: %w", err)
}
physical, err := filepath.EvalSymlinks(abs)
if err != nil {
return false, fmt.Errorf("inspect repository directory: %w", err)
}
info, err := os.Stat(physical)
if err != nil {
return false, fmt.Errorf("inspect repository directory: %w", err)
}
if !info.IsDir() {
return false, fmt.Errorf("inspect repository directory %q: not a directory", directory)
}
for dir := physical; ; dir = filepath.Dir(dir) {
_, err := os.Lstat(filepath.Join(dir, ".git"))
if err == nil {
return false, nil
}
if !errors.Is(err, os.ErrNotExist) {
return false, fmt.Errorf("inspect Git marker in %s: %w", dir, err)
}
parent := filepath.Dir(dir)
if parent == dir {
return true, nil
}
for _, boundary := range boundaries {
if parent == boundary {
return true, nil
}
}
}
}
// CaptureHead records the checkout commit before identity derived from files
// in that commit is inspected.
func CaptureHead(ctx context.Context, repositoryDir string) (string, error) {
head, err := run(ctx, repositoryDir, nil, "rev-parse", "--verify", "HEAD^{commit}")
if err != nil {
return "", fmt.Errorf("read checkout HEAD: %w", err)
}
head = strings.TrimSpace(head)
if !validObjectID(head) {
return "", fmt.Errorf("Git returned an invalid checkout commit %q", head)
}
return head, nil
}
// InspectTaggedSource reads the local HEAD, worktree state, and selected tag.
// Both lightweight historical tags and annotated tags are supported.
func InspectTaggedSource(ctx context.Context, repositoryDir, tag string) (TaggedSource, error) {
if err := CheckTagRef(ctx, repositoryDir, tag); err != nil {
return TaggedSource{}, err
}
var source TaggedSource
head, err := run(ctx, repositoryDir, nil, "rev-parse", "--verify", "HEAD^{commit}")
if err != nil {
return TaggedSource{}, fmt.Errorf("read checkout HEAD: %w", err)
}
source.Head = strings.TrimSpace(head)
if !validObjectID(source.Head) {
return TaggedSource{}, fmt.Errorf("Git returned an invalid checkout commit %q", source.Head)
}
status, err := run(ctx, repositoryDir, nil, "status", "--porcelain", "--untracked-files=all")
if err != nil {
return TaggedSource{}, fmt.Errorf("inspect tagged checkout state: %w", err)
}
source.Status = strings.TrimSpace(status)
ref := "refs/tags/" + tag
object, err := run(ctx, repositoryDir, nil, "rev-parse", "--verify", ref)
if err != nil {
return TaggedSource{}, fmt.Errorf("read local tag %s: %w", tag, err)
}
source.TagObject = strings.TrimSpace(object)
if !validObjectID(source.TagObject) {
return TaggedSource{}, fmt.Errorf("Git returned an invalid tag object %q", source.TagObject)
}
peeled, err := run(ctx, repositoryDir, nil, "rev-parse", "--verify", ref+"^{commit}")
if err != nil {
return TaggedSource{}, fmt.Errorf("resolve local tag %s to a commit: %w", tag, err)
}
source.TagCommit = strings.TrimSpace(peeled)
if !validObjectID(source.TagCommit) {
return TaggedSource{}, fmt.Errorf("Git returned an invalid tagged commit %q", source.TagCommit)
}
tagType, err := run(ctx, repositoryDir, nil, "cat-file", "-t", ref)
if err != nil {
return TaggedSource{}, fmt.Errorf("inspect local tag %s: %w", tag, err)
}
source.TagObjectType = strings.TrimSpace(tagType)
if source.TagObjectType != "tag" && source.TagObjectType != "commit" {
return TaggedSource{}, fmt.Errorf("local tag %s has unsupported object type %q", tag, source.TagObjectType)
}
return source, nil
}
// ReadCommitFile reads a repository-relative file from a specific commit.
func ReadCommitFile(ctx context.Context, repositoryDir, commit, name string) ([]byte, error) {
if !validObjectID(commit) || name == "" || path.IsAbs(name) || path.Clean(name) != name || strings.HasPrefix(name, "../") || name == ".." || strings.Contains(name, "\\") {
return nil, fmt.Errorf("invalid committed file identity %q at %q", name, commit)
}
content, err := readLocalObjects(ctx, repositoryDir, nil, "show", commit+":"+name)
if err != nil {
return nil, fmt.Errorf("read committed file %q: %w", name, err)
}
return content, nil
}
// CommittedPaths resolves individual paths from captured objects. It requests
// only metadata and link contents needed for traversal.
func CommittedPaths(repositoryDir, commit string) PathResolver {
metadata := make(map[string]TreeEntry)
return PathResolver{
RepositoryDir: repositoryDir,
Lookup: func(ctx context.Context, name string) (TreeEntry, bool, error) {
if err := ctx.Err(); err != nil {
return TreeEntry{}, false, err
}
if entry, seen := metadata[name]; seen {
return entry, entry.ObjectID != "", nil
}
if !validObjectID(commit) || name == "" || path.IsAbs(name) || path.Clean(name) != name || name == ".." || strings.HasPrefix(name, "../") {
return TreeEntry{}, false, fmt.Errorf("invalid committed path identity %q at %q", name, commit)
}
output, err := readLocalObjects(ctx, repositoryDir, nil, "--literal-pathspecs", "ls-tree", "-z", "--full-tree", commit, "--", name)
if err != nil {
return TreeEntry{}, false, fmt.Errorf("inspect committed path %q: %w", name, err)
}
entries, err := parseTreeEntries(output)
if err != nil {
return TreeEntry{}, false, err
}
for _, entry := range entries {
if entry.Path == name {
metadata[name] = entry
return entry, true, nil
}
}
metadata[name] = TreeEntry{}
return TreeEntry{}, false, nil
},
ReadLink: func(ctx context.Context, entry TreeEntry) (string, error) {
contents, err := ReadBlob(ctx, repositoryDir, entry.ObjectID)
return string(contents), err
},
}
}
// ReadTree enumerates path, type, mode, and object metadata for a commit.
// Blob sizes and contents are inspected separately. 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 := readLocalObjects(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(output)
if err != nil {
return nil, fmt.Errorf("parse committed tree %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 := readLocalObjects(ctx, repositoryDir, nil, "cat-file", "blob", objectID)
if err != nil {
return nil, fmt.Errorf("read blob %s: %w", objectID, err)
}
return 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
}
// PopulateBlobSizes fills unknown sizes for the supplied blob entries using
// local objects. Callers select the entries whose sizes their check needs.
func PopulateBlobSizes(ctx context.Context, repositoryDir string, entries []TreeEntry) error {
if err := ctx.Err(); err != nil {
return err
}
var blobIndexes []int
var input strings.Builder
for index := range entries {
if entries[index].Type != "blob" || entries[index].HasSize {
continue
}
blobIndexes = append(blobIndexes, index)
input.WriteString(entries[index].ObjectID)
input.WriteByte('\n')
}
if len(blobIndexes) == 0 {
return nil
}
output, err := readLocalObjects(ctx, repositoryDir, strings.NewReader(input.String()),
"cat-file", "--batch-check=%(objectname) %(objecttype) %(objectsize)")
if err != nil {
return err
}
lines := strings.Split(strings.TrimSuffix(string(output), "\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) == 2 && fields[0] == entry.ObjectID && fields[1] == "missing" {
return fmt.Errorf("committed blob %s for %q is unavailable locally", entry.ObjectID, entry.Path)
}
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
}
// readLocalObjects keeps source inspection separate from network preparation.
func readLocalObjects(ctx context.Context, repositoryDir string, input io.Reader, args ...string) ([]byte, error) {
var stdout, stderr bytes.Buffer
err := process.Run(ctx, process.Invocation{
Args: append([]string{"git"}, args...),
Dir: repositoryDir,
// Disable transports as well for Git versions predating lazy-fetch control.
Env: map[string]string{"GIT_NO_LAZY_FETCH": "1", "GIT_ALLOW_PROTOCOL": ""},
Stdin: input,
Stdout: &stdout,
Stderr: &stderr,
})
if err != nil {
if message := strings.TrimSpace(stderr.String()); message != "" {
return nil, fmt.Errorf("read local Git objects: %s: %w", message, err)
}
return nil, err
}
return stdout.Bytes(), nil
}