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 } return tree.resolveDestination(ctx, path.Dir(sourcePath), strings.Split(destination, "/"), make(map[string]bool)) } func (tree *CandidateTree) resolveDestination(ctx context.Context, directory string, components []string, active map[string]bool) (Destination, error) { for index, component := range components { if err := ctx.Err(); err != nil { return Destination{}, err } remaining := components[index+1:] switch component { case "", ".": continue case "..": if directory == "." { return Destination{Kind: DestinationOutsideCheckout, Path: unresolvedPath("..", remaining)}, nil } directory = path.Dir(directory) continue } partial := path.Join(directory, component) entry, exists := tree.Entry(partial) if !exists { if _, isDirectory := tree.directories[partial]; !isDirectory { return Destination{Kind: DestinationMissing, Path: unresolvedPath(partial, remaining)}, nil } directory = partial continue } if entry.Mode == "160000" { return Destination{Kind: DestinationSubmodule, Path: unresolvedPath(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: unresolvedPath(target, remaining)}, nil } active[partial] = true destination, err := tree.resolveDestination(ctx, directory, strings.Split(target, "/"), active) delete(active, partial) if err != nil || len(remaining) == 0 { return destination, err } switch destination.Kind { case DestinationDirectory: return tree.resolveDestination(ctx, destination.Path, remaining, active) case DestinationEntry: destination.Kind = DestinationMissing case DestinationSymlinkCycle: return destination, nil } destination.Path = unresolvedPath(destination.Path, remaining) return destination, nil } if len(remaining) != 0 { return Destination{Kind: DestinationMissing, Path: unresolvedPath(partial, remaining)}, nil } return Destination{Kind: DestinationEntry, Path: partial}, nil } return Destination{Kind: DestinationDirectory, Path: directory}, nil } // unresolvedPath preserves components that could not be traversed. Cleaning // them would hide missing entries, non-directories, or checkout boundaries. func unresolvedPath(base string, remaining []string) string { if len(remaining) == 0 { return base } return base + "/" + strings.Join(remaining, "/") }