175 lines
5.9 KiB
Go
175 lines
5.9 KiB
Go
package validation
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"path"
|
|
"strings"
|
|
|
|
"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
|
|
directories map[string]struct{}
|
|
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)),
|
|
directories: make(map[string]struct{}),
|
|
blobs: make(map[string][]byte),
|
|
}
|
|
tree.directories["."] = struct{}{}
|
|
for index, entry := range entries {
|
|
tree.byPath[entry.Path] = index
|
|
for directory := path.Dir(entry.Path); directory != "."; directory = path.Dir(directory) {
|
|
tree.directories[directory] = struct{}{}
|
|
}
|
|
}
|
|
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
|
|
}
|
|
|
|
// DestinationKind classifies a committed local destination.
|
|
type DestinationKind string
|
|
|
|
const (
|
|
DestinationEntry DestinationKind = "entry"
|
|
DestinationDirectory DestinationKind = "directory"
|
|
DestinationMissing DestinationKind = "missing"
|
|
DestinationOutsideCheckout DestinationKind = "outside-checkout"
|
|
DestinationSubmodule DestinationKind = "submodule"
|
|
DestinationSymlinkCycle DestinationKind = "symlink-cycle"
|
|
)
|
|
|
|
// Destination records the resolved identity and its classification.
|
|
type Destination struct {
|
|
Kind DestinationKind
|
|
Path string
|
|
}
|
|
|
|
// ResolveDestination resolves a repository-relative destination from the
|
|
// source file's directory using only the captured Git tree.
|
|
func (tree *CandidateTree) ResolveDestination(ctx context.Context, sourcePath, destination string) (Destination, error) {
|
|
if err := ctx.Err(); err != nil {
|
|
return Destination{}, err
|
|
}
|
|
if strings.HasPrefix(destination, "/") {
|
|
return Destination{Kind: DestinationOutsideCheckout, Path: path.Clean(destination)}, nil
|
|
}
|
|
resolved := path.Clean(path.Join(path.Dir(sourcePath), destination))
|
|
return tree.resolveDestination(ctx, resolved, make(map[string]bool))
|
|
}
|
|
|
|
func (tree *CandidateTree) resolveDestination(ctx context.Context, resolved string, active map[string]bool) (Destination, error) {
|
|
if err := ctx.Err(); err != nil {
|
|
return Destination{}, err
|
|
}
|
|
if outsideCheckout(resolved) {
|
|
return Destination{Kind: DestinationOutsideCheckout, Path: resolved}, nil
|
|
}
|
|
components := strings.Split(resolved, "/")
|
|
partial := ""
|
|
for index, component := range components {
|
|
if err := ctx.Err(); err != nil {
|
|
return Destination{}, err
|
|
}
|
|
partial = path.Join(partial, component)
|
|
remaining := strings.Join(components[index+1:], "/")
|
|
entry, exists := tree.Entry(partial)
|
|
if !exists {
|
|
if _, directory := tree.directories[partial]; !directory {
|
|
return Destination{Kind: DestinationMissing, Path: resolved}, nil
|
|
}
|
|
continue
|
|
}
|
|
if entry.Mode == "160000" {
|
|
return Destination{Kind: DestinationSubmodule, Path: path.Join(partial, remaining)}, nil
|
|
}
|
|
if entry.Mode == "120000" {
|
|
if active[partial] {
|
|
return Destination{Kind: DestinationSymlinkCycle, Path: partial}, nil
|
|
}
|
|
linkText, err := tree.ReadFile(ctx, partial)
|
|
if err != nil {
|
|
return Destination{}, fmt.Errorf("read committed symlink %q: %w", partial, err)
|
|
}
|
|
target := string(linkText)
|
|
if strings.HasPrefix(target, "/") {
|
|
return Destination{Kind: DestinationOutsideCheckout, Path: target}, nil
|
|
}
|
|
active[partial] = true
|
|
destination, err := tree.resolveDestination(ctx, path.Join(path.Dir(partial), target), active)
|
|
delete(active, partial)
|
|
if err != nil || remaining == "" || destination.Kind != DestinationDirectory {
|
|
if err == nil && remaining != "" && destination.Kind == DestinationEntry {
|
|
return Destination{Kind: DestinationMissing, Path: path.Join(destination.Path, remaining)}, nil
|
|
}
|
|
return destination, err
|
|
}
|
|
return tree.resolveDestination(ctx, path.Join(destination.Path, remaining), active)
|
|
}
|
|
if remaining != "" {
|
|
return Destination{Kind: DestinationMissing, Path: resolved}, nil
|
|
}
|
|
return Destination{Kind: DestinationEntry, Path: partial}, nil
|
|
}
|
|
if _, directory := tree.directories[resolved]; directory {
|
|
return Destination{Kind: DestinationDirectory, Path: resolved}, nil
|
|
}
|
|
return Destination{Kind: DestinationMissing, Path: resolved}, nil
|
|
}
|
|
|
|
func outsideCheckout(name string) bool {
|
|
return name == ".." || strings.HasPrefix(name, "../")
|
|
}
|