Select committed validation inputs by module
This commit is contained in:
@@ -5,6 +5,7 @@ import (
|
||||
"fmt"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"gitea.maximumdirect.net/eric/ReleaseKit/internal/git"
|
||||
@@ -16,6 +17,7 @@ type CandidateTree struct {
|
||||
context context.Context
|
||||
repositoryDir string
|
||||
entries []git.TreeEntry
|
||||
inputEntries []git.TreeEntry
|
||||
byPath map[string]int
|
||||
directories map[string]struct{}
|
||||
blobReader *git.BlobReader
|
||||
@@ -25,9 +27,28 @@ type CandidateTree struct {
|
||||
closeErr error
|
||||
}
|
||||
|
||||
// TreeScope selects the automatic input files for one physical module and
|
||||
// optional repository-shared files. ModuleDir is an absolute path. A nil
|
||||
// SharedFiles slice selects the optional default files; a non-nil slice
|
||||
// replaces those defaults.
|
||||
type TreeScope struct {
|
||||
ModuleDir string
|
||||
SharedFiles []string
|
||||
}
|
||||
|
||||
// ReadCandidateTree enumerates the captured commit without reading from the
|
||||
// worktree. The supplied context also governs the scoped reader through closure.
|
||||
func ReadCandidateTree(ctx context.Context, repositoryDir, commit string) (*CandidateTree, error) {
|
||||
repositoryAbs, err := filepath.Abs(repositoryDir)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("resolve candidate repository directory: %w", err)
|
||||
}
|
||||
return ReadCandidateTreeForScope(ctx, repositoryDir, commit, TreeScope{ModuleDir: repositoryAbs})
|
||||
}
|
||||
|
||||
// ReadCandidateTreeForScope retains the complete committed tree for reference
|
||||
// lookup and separately constructs the selected automatic input paths.
|
||||
func ReadCandidateTreeForScope(ctx context.Context, repositoryDir, commit string, scope TreeScope) (*CandidateTree, error) {
|
||||
entries, err := git.ReadTree(ctx, repositoryDir, commit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -47,6 +68,11 @@ func ReadCandidateTree(ctx context.Context, repositoryDir, commit string) (*Cand
|
||||
tree.directories[directory] = struct{}{}
|
||||
}
|
||||
}
|
||||
tree.inputEntries, err = selectInputEntries(repositoryDir, entries, tree.byPath, scope)
|
||||
if err != nil {
|
||||
_ = tree.Close()
|
||||
return nil, err
|
||||
}
|
||||
return tree, nil
|
||||
}
|
||||
|
||||
@@ -55,6 +81,136 @@ func (tree *CandidateTree) Entries() []git.TreeEntry {
|
||||
return append([]git.TreeEntry(nil), tree.entries...)
|
||||
}
|
||||
|
||||
// InputEntries returns the ordered committed files selected for automatic
|
||||
// checks. It is separate from Entries so reference resolution retains the full
|
||||
// repository tree.
|
||||
func (tree *CandidateTree) InputEntries() []git.TreeEntry {
|
||||
return append([]git.TreeEntry(nil), tree.inputEntries...)
|
||||
}
|
||||
|
||||
func selectInputEntries(repositoryDir string, entries []git.TreeEntry, byPath map[string]int, scope TreeScope) ([]git.TreeEntry, error) {
|
||||
repositoryAbs, err := filepath.Abs(repositoryDir)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("resolve candidate repository directory: %w", err)
|
||||
}
|
||||
moduleDir := scope.ModuleDir
|
||||
if moduleDir == "" {
|
||||
moduleDir = repositoryAbs
|
||||
}
|
||||
if !filepath.IsAbs(moduleDir) {
|
||||
return nil, fmt.Errorf("selected module directory must be absolute: %q", moduleDir)
|
||||
}
|
||||
moduleAbs, err := filepath.Abs(moduleDir)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("resolve selected module directory: %w", err)
|
||||
}
|
||||
relative, err := filepath.Rel(repositoryAbs, moduleAbs)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("make selected module relative to repository: %w", err)
|
||||
}
|
||||
if relative == ".." || strings.HasPrefix(relative, ".."+string(filepath.Separator)) || filepath.IsAbs(relative) {
|
||||
return nil, fmt.Errorf("selected module directory %q is outside repository %q", moduleDir, repositoryDir)
|
||||
}
|
||||
modulePath := filepath.ToSlash(relative)
|
||||
if relative == "." {
|
||||
modulePath = "."
|
||||
}
|
||||
|
||||
boundaries := make([]string, 0)
|
||||
for _, entry := range entries {
|
||||
moduleRelative, ok := relativeToModule(entry.Path, modulePath)
|
||||
if !ok || moduleRelative == "go.mod" || path.Base(moduleRelative) != "go.mod" {
|
||||
continue
|
||||
}
|
||||
if insideVendor(moduleRelative) {
|
||||
continue
|
||||
}
|
||||
boundaries = append(boundaries, path.Dir(entry.Path))
|
||||
}
|
||||
selected := make(map[string]git.TreeEntry)
|
||||
for _, entry := range entries {
|
||||
moduleRelative, ok := relativeToModule(entry.Path, modulePath)
|
||||
if !ok || moduleRelative == "" {
|
||||
continue
|
||||
}
|
||||
excluded := false
|
||||
for _, boundary := range boundaries {
|
||||
if entry.Path == boundary || strings.HasPrefix(entry.Path, boundary+"/") {
|
||||
excluded = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !excluded {
|
||||
selected[entry.Path] = entry
|
||||
}
|
||||
}
|
||||
|
||||
sharedFiles := scope.SharedFiles
|
||||
usingDefaults := sharedFiles == nil
|
||||
if usingDefaults {
|
||||
sharedFiles = []string{"COPYRIGHT", "LICENSE", "docs/oss/attribution.md"}
|
||||
}
|
||||
for _, requested := range sharedFiles {
|
||||
name, err := normalizeSharedFile(requested)
|
||||
if err != nil {
|
||||
return nil, &Failure{Check: SharedFiles, Findings: []Finding{{Path: requested, Message: err.Error()}}}
|
||||
}
|
||||
index, exists := byPath[name]
|
||||
if !exists || entries[index].Type != "blob" {
|
||||
if usingDefaults {
|
||||
continue
|
||||
}
|
||||
return nil, &Failure{Check: SharedFiles, Findings: []Finding{{Path: name, Message: "selected shared file is not present in the committed tree"}}}
|
||||
}
|
||||
selected[name] = entries[index]
|
||||
}
|
||||
|
||||
paths := make([]string, 0, len(selected))
|
||||
for name := range selected {
|
||||
paths = append(paths, name)
|
||||
}
|
||||
sort.Strings(paths)
|
||||
inputs := make([]git.TreeEntry, 0, len(paths))
|
||||
for _, name := range paths {
|
||||
inputs = append(inputs, selected[name])
|
||||
}
|
||||
return inputs, nil
|
||||
}
|
||||
|
||||
func relativeToModule(repositoryPath, modulePath string) (string, bool) {
|
||||
if modulePath == "." {
|
||||
return repositoryPath, true
|
||||
}
|
||||
if repositoryPath == modulePath {
|
||||
return "", true
|
||||
}
|
||||
prefix := modulePath + "/"
|
||||
if !strings.HasPrefix(repositoryPath, prefix) {
|
||||
return "", false
|
||||
}
|
||||
return strings.TrimPrefix(repositoryPath, prefix), true
|
||||
}
|
||||
|
||||
func insideVendor(moduleRelative string) bool {
|
||||
for _, component := range strings.Split(moduleRelative, "/") {
|
||||
if component == "vendor" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func normalizeSharedFile(name string) (string, error) {
|
||||
if name == "" || path.IsAbs(name) || strings.Contains(name, "\\") {
|
||||
return "", fmt.Errorf("shared file path must be a repository-relative file path")
|
||||
}
|
||||
clean := path.Clean(name)
|
||||
if clean == "." || clean == ".." || strings.HasPrefix(clean, "../") {
|
||||
return "", fmt.Errorf("shared file path must stay within the repository")
|
||||
}
|
||||
return clean, nil
|
||||
}
|
||||
|
||||
// ReadFile returns newly allocated, caller-owned bytes for a committed blob.
|
||||
// Regular-file content is not cached, so repeated reads request the object
|
||||
// again through the tree's scoped blob reader.
|
||||
|
||||
Reference in New Issue
Block a user