Resolve candidate file destinations

This commit is contained in:
2026-10-07 18:31:18 +00:00
parent 1cad13649e
commit 5848307948
4 changed files with 218 additions and 2 deletions

View File

@@ -3,6 +3,8 @@ package validation
import (
"context"
"fmt"
"path"
"strings"
"gitea.maximumdirect.net/eric/ReleaseKit/internal/git"
)
@@ -13,6 +15,7 @@ type CandidateTree struct {
repositoryDir string
entries []git.TreeEntry
byPath map[string]int
directories map[string]struct{}
blobs map[string][]byte
}
@@ -27,10 +30,15 @@ func ReadCandidateTree(ctx context.Context, repositoryDir, commit string) (*Cand
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
}
@@ -70,3 +78,89 @@ func (tree *CandidateTree) Entry(path string) (git.TreeEntry, bool) {
}
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 strings.HasPrefix(destination, "/") {
return Destination{Kind: DestinationOutsideCheckout, Path: path.Clean(destination)}, nil
}
resolved := path.Clean(path.Join(path.Dir(sourcePath), destination))
if outsideCheckout(resolved) {
return Destination{Kind: DestinationOutsideCheckout, Path: resolved}, nil
}
seen := make(map[string]struct{})
for {
components := strings.Split(resolved, "/")
partial := ""
restarted := false
for index, component := range components {
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" {
state := partial + "\x00" + remaining
if _, cycle := seen[state]; cycle {
return Destination{Kind: DestinationSymlinkCycle, Path: partial}, nil
}
seen[state] = struct{}{}
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
}
resolved = path.Clean(path.Join(path.Dir(partial), target, remaining))
if outsideCheckout(resolved) {
return Destination{Kind: DestinationOutsideCheckout, Path: resolved}, nil
}
restarted = true
break
}
if remaining != "" {
return Destination{Kind: DestinationMissing, Path: resolved}, nil
}
return Destination{Kind: DestinationEntry, Path: partial}, nil
}
if restarted {
continue
}
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, "../")
}