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 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, "../") }