package validation import ( "context" "fmt" "path" "path/filepath" "sort" "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 { context context.Context repositoryDir string modulePath string entries []git.TreeEntry inputEntries []git.TreeEntry byPath map[string]int directories map[string]struct{} blobReader *git.BlobReader blobReads uint64 symlinkTargets map[string]string closed bool 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 } modulePath, err := repositoryModulePath(repositoryDir, scope.ModuleDir) if err != nil { return nil, err } tree := &CandidateTree{ context: ctx, repositoryDir: repositoryDir, modulePath: modulePath, entries: entries, byPath: make(map[string]int, len(entries)), directories: make(map[string]struct{}), symlinkTargets: make(map[string]string), } 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{}{} } } tree.inputEntries, err = selectInputEntries(entries, tree.byPath, modulePath, scope.SharedFiles) if err != nil { _ = tree.Close() return nil, err } 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...) } // 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(entries []git.TreeEntry, byPath map[string]int, modulePath string, sharedFiles []string) ([]git.TreeEntry, error) { 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 } } usingDefaults := sharedFiles == nil if usingDefaults { sharedFiles = []string{"COPYRIGHT", "LICENSE", "docs/oss/attribution.md"} } for _, requested := range sharedFiles { name, err := normalizeSharedFile(requested) if err != nil { cause := fmt.Errorf("invalid shared file path %q: %w", requested, err) return nil, &Failure{Check: SharedFiles, Findings: []Finding{{Path: requested, Message: cause.Error()}}, Cause: cause} } index, exists := byPath[name] if !exists || entries[index].Type != "blob" { if usingDefaults { continue } cause := fmt.Errorf("selected shared file %q is not present in the committed tree", name) return nil, &Failure{Check: SharedFiles, Findings: []Finding{{Path: name, Message: "selected shared file is not present in the committed tree"}}, Cause: cause} } 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 repositoryModulePath(repositoryDir, moduleDir string) (string, error) { repositoryAbs, err := filepath.Abs(repositoryDir) if err != nil { return "", fmt.Errorf("resolve candidate repository directory: %w", err) } if moduleDir == "" { moduleDir = repositoryAbs } if !filepath.IsAbs(moduleDir) { return "", fmt.Errorf("selected module directory must be absolute: %q", moduleDir) } moduleAbs, err := filepath.Abs(moduleDir) if err != nil { return "", fmt.Errorf("resolve selected module directory: %w", err) } relative, err := filepath.Rel(repositoryAbs, moduleAbs) if err != nil { return "", fmt.Errorf("make selected module relative to repository: %w", err) } if relative == ".." || strings.HasPrefix(relative, ".."+string(filepath.Separator)) || filepath.IsAbs(relative) { return "", fmt.Errorf("selected module directory %q is outside repository %q", moduleDir, repositoryDir) } modulePath := filepath.ToSlash(relative) if relative == "." { modulePath = "." } return modulePath, 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. func (tree *CandidateTree) ReadFile(ctx context.Context, path string) ([]byte, error) { if tree.closed { return nil, fmt.Errorf("read committed path %q: candidate tree is closed", path) } 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 entry.Mode == "120000" { target, err := tree.readSymlinkTarget(ctx, entry) if err != nil { return nil, err } return []byte(target), nil } return tree.readBlob(ctx, entry) } func (tree *CandidateTree) readSymlinkTarget(ctx context.Context, entry git.TreeEntry) (string, error) { if tree.closed { return "", fmt.Errorf("read committed path %q: candidate tree is closed", entry.Path) } if entry.Type != "blob" || entry.Mode != "120000" { return "", fmt.Errorf("committed path %q is not a symlink blob", entry.Path) } if target, ok := tree.symlinkTargets[entry.ObjectID]; ok { return target, nil } contents, err := tree.readBlob(ctx, entry) if err != nil { return "", err } target := string(contents) if tree.symlinkTargets == nil { tree.symlinkTargets = make(map[string]string) } tree.symlinkTargets[entry.ObjectID] = target return target, nil } func (tree *CandidateTree) readBlob(ctx context.Context, entry git.TreeEntry) ([]byte, error) { if tree.closed { return nil, fmt.Errorf("read committed path %q: candidate tree is closed", entry.Path) } if tree.blobReader == nil { // The operation context governs shutdown independently of each read. operationContext := tree.context if operationContext == nil { operationContext = context.Background() } tree.blobReader = git.NewBlobReader(operationContext, tree.repositoryDir) } tree.blobReads++ contents, err := tree.blobReader.Read(ctx, entry.ObjectID) if err != nil { return nil, fmt.Errorf("read committed path %q: %w", entry.Path, err) } return contents, nil } // Close closes the scoped local Git blob reader, if content was requested. // Metadata-only trees have no child process to close. func (tree *CandidateTree) Close() error { if tree.closed { return tree.closeErr } tree.closed = true if tree.blobReader != nil { tree.closeErr = tree.blobReader.Close() } return tree.closeErr } // 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 = git.DestinationKind const ( DestinationEntry = git.DestinationEntry DestinationDirectory = git.DestinationDirectory DestinationMissing = git.DestinationMissing DestinationOutsideCheckout = git.DestinationOutsideCheckout DestinationSubmodule = git.DestinationSubmodule DestinationSymlinkCycle = git.DestinationSymlinkCycle ) type Destination = git.Destination // ResolveDestination uses shared committed reference traversal with the tree's // indexed metadata and scoped symlink reader. func (tree *CandidateTree) ResolveDestination(ctx context.Context, sourcePath, destination string) (Destination, error) { resolver := git.PathResolver{ RepositoryDir: tree.repositoryDir, Lookup: func(_ context.Context, name string) (git.TreeEntry, bool, error) { if entry, exists := tree.Entry(name); exists { return entry, true, nil } _, exists := tree.directories[name] return git.TreeEntry{Path: name, Type: "tree"}, exists, nil }, ReadLink: tree.readSymlinkTarget, } return resolver.ResolveDestination(ctx, sourcePath, destination) }