229 lines
8.8 KiB
Go
229 lines
8.8 KiB
Go
// Package releaseidentity resolves and derives the identity of one Go module
|
|
// release within a repository.
|
|
package releaseidentity
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"os"
|
|
"path"
|
|
"path/filepath"
|
|
"strings"
|
|
|
|
"gitea.maximumdirect.net/eric/ReleaseKit/internal/git"
|
|
"gitea.maximumdirect.net/eric/ReleaseKit/internal/versioncontract"
|
|
"golang.org/x/mod/modfile"
|
|
"golang.org/x/mod/module"
|
|
)
|
|
|
|
// Selection identifies the normalized repository and selected module
|
|
// directories. ModuleDir is an absolute path; ModuleSubdir is its canonical
|
|
// repository-relative slash path, with "." representing the root.
|
|
type Selection struct {
|
|
RepositoryDir string
|
|
ModuleDir string
|
|
ModuleSubdir string
|
|
}
|
|
|
|
// Identity is the release identity derived from one selected module.
|
|
type Identity struct {
|
|
Selection
|
|
ModulePath string
|
|
Version string
|
|
Tag string
|
|
NotePath string
|
|
}
|
|
|
|
// ResolveSelection normalizes a repository-relative module selection and
|
|
// confirms its final filesystem location remains inside the repository.
|
|
func ResolveSelection(repositoryDir, moduleDir string) (Selection, error) {
|
|
if moduleDir == "" {
|
|
moduleDir = "."
|
|
}
|
|
if filepath.IsAbs(moduleDir) {
|
|
return Selection{}, fmt.Errorf("module directory must be repository-relative: %q", moduleDir)
|
|
}
|
|
repositoryAbs, err := filepath.Abs(repositoryDir)
|
|
if err != nil {
|
|
return Selection{}, fmt.Errorf("resolve repository directory: %w", err)
|
|
}
|
|
repositoryAbs, err = filepath.EvalSymlinks(repositoryAbs)
|
|
if err != nil {
|
|
return Selection{}, fmt.Errorf("resolve repository directory %q: %w", repositoryDir, err)
|
|
}
|
|
moduleAbs, err := filepath.Abs(filepath.Join(repositoryAbs, moduleDir))
|
|
if err != nil {
|
|
return Selection{}, fmt.Errorf("resolve module directory %q: %w", moduleDir, err)
|
|
}
|
|
moduleAbs, err = filepath.EvalSymlinks(moduleAbs)
|
|
if err != nil {
|
|
return Selection{}, fmt.Errorf("resolve module directory %q: %w", moduleDir, err)
|
|
}
|
|
relative, err := filepath.Rel(repositoryAbs, moduleAbs)
|
|
if err != nil {
|
|
return Selection{}, fmt.Errorf("make module directory relative to repository: %w", err)
|
|
}
|
|
if relative == ".." || strings.HasPrefix(relative, ".."+string(filepath.Separator)) || filepath.IsAbs(relative) {
|
|
return Selection{}, fmt.Errorf("module directory %q resolves outside repository %q", moduleDir, repositoryDir)
|
|
}
|
|
if relative == "." {
|
|
return Selection{RepositoryDir: repositoryAbs, ModuleDir: moduleAbs, ModuleSubdir: "."}, nil
|
|
}
|
|
return Selection{RepositoryDir: repositoryAbs, ModuleDir: moduleAbs, ModuleSubdir: filepath.ToSlash(relative)}, nil
|
|
}
|
|
|
|
// FromCommitted derives identity from the selected module declaration at a
|
|
// captured commit. The selected module must contain its own go.mod.
|
|
func FromCommitted(ctx context.Context, repositoryDir, moduleDir, version, commit string) (Identity, error) {
|
|
resolver, err := committedPaths(repositoryDir, commit)
|
|
if err != nil {
|
|
return Identity{Version: version}, err
|
|
}
|
|
selection, err := selectCommittedDirectory(ctx, resolver, moduleDir)
|
|
if err != nil {
|
|
return Identity{Selection: selection, Version: version}, err
|
|
}
|
|
return fromCommittedSelection(ctx, selection, version, commit, resolver)
|
|
}
|
|
|
|
// ResolveCommittedSelection resolves directory selection without reading the
|
|
// module declaration or requiring the directory to be materialized.
|
|
func ResolveCommittedSelection(ctx context.Context, repositoryDir, moduleDir, commit string) (Selection, error) {
|
|
resolver, err := committedPaths(repositoryDir, commit)
|
|
if err != nil {
|
|
return Selection{}, err
|
|
}
|
|
return selectCommittedDirectory(ctx, resolver, moduleDir)
|
|
}
|
|
|
|
func committedPaths(repositoryDir, commit string) (git.PathResolver, error) {
|
|
repositoryAbs, err := filepath.Abs(repositoryDir)
|
|
if err != nil {
|
|
return git.PathResolver{}, err
|
|
}
|
|
repositoryAbs, err = filepath.EvalSymlinks(repositoryAbs)
|
|
if err != nil {
|
|
return git.PathResolver{}, err
|
|
}
|
|
return git.CommittedPaths(repositoryAbs, commit), nil
|
|
}
|
|
|
|
func selectCommittedDirectory(ctx context.Context, resolver git.PathResolver, moduleDir string) (Selection, error) {
|
|
selection := Selection{RepositoryDir: resolver.RepositoryDir}
|
|
if moduleDir == "" {
|
|
moduleDir = "."
|
|
}
|
|
if filepath.IsAbs(moduleDir) {
|
|
return selection, fmt.Errorf("module directory must be repository-relative: %q", moduleDir)
|
|
}
|
|
directory, err := resolver.ResolveDestination(ctx, "go.mod", filepath.ToSlash(moduleDir))
|
|
if err != nil {
|
|
return selection, fmt.Errorf("resolve committed module directory: %w", err)
|
|
}
|
|
if directory.Kind != git.DestinationDirectory {
|
|
return selection, fmt.Errorf("selected committed module directory %q resolves to %s at %q", moduleDir, directory.Kind, directory.Path)
|
|
}
|
|
selection.ModuleDir = filepath.Join(resolver.RepositoryDir, filepath.FromSlash(directory.Path))
|
|
selection.ModuleSubdir = directory.Path
|
|
return selection, nil
|
|
}
|
|
|
|
// FromCommittedSelection derives identity from a previously resolved module
|
|
// selection and the selected module declaration at a captured commit.
|
|
func FromCommittedSelection(ctx context.Context, selection Selection, version, commit string) (Identity, error) {
|
|
return fromCommittedSelection(ctx, selection, version, commit, git.CommittedPaths(selection.RepositoryDir, commit))
|
|
}
|
|
|
|
func fromCommittedSelection(ctx context.Context, selection Selection, version, commit string, resolver git.PathResolver) (Identity, error) {
|
|
identity := Identity{Selection: selection, Version: version}
|
|
if err := ctx.Err(); err != nil {
|
|
return identity, fmt.Errorf("resolve committed release identity: %w", err)
|
|
}
|
|
declarationPath, err := resolver.ResolveDestination(ctx, "go.mod", moduleFilePath(selection.ModuleSubdir))
|
|
if err != nil {
|
|
return identity, fmt.Errorf("resolve selected module declaration at %s: %w", commit, err)
|
|
}
|
|
if declarationPath.Kind != git.DestinationEntry {
|
|
return identity, fmt.Errorf("selected module declaration resolves to %s at %q", declarationPath.Kind, declarationPath.Path)
|
|
}
|
|
entry, _, err := resolver.Lookup(ctx, declarationPath.Path)
|
|
if err != nil {
|
|
return identity, err
|
|
}
|
|
declaration, err := git.ReadBlob(ctx, selection.RepositoryDir, entry.ObjectID)
|
|
if err != nil {
|
|
return identity, fmt.Errorf("read selected module declaration at %s: %w", commit, err)
|
|
}
|
|
return Derive(selection, declaration, version)
|
|
}
|
|
|
|
// FromWorkingTree derives identity from the selected module declaration in the
|
|
// working tree. The selected module must contain its own go.mod.
|
|
func FromWorkingTree(ctx context.Context, repositoryDir, moduleDir, version string) (Identity, error) {
|
|
selection, err := ResolveSelection(repositoryDir, moduleDir)
|
|
identity := Identity{Selection: selection, Version: version}
|
|
if err != nil {
|
|
return identity, err
|
|
}
|
|
if err := ctx.Err(); err != nil {
|
|
return identity, fmt.Errorf("resolve working-tree release identity: %w", err)
|
|
}
|
|
declarationPath := filepath.Join(selection.ModuleDir, "go.mod")
|
|
declaration, err := os.ReadFile(declarationPath)
|
|
if err != nil {
|
|
return identity, fmt.Errorf("read selected module declaration %q: %w", declarationPath, err)
|
|
}
|
|
return Derive(selection, declaration, version)
|
|
}
|
|
|
|
// Derive applies Go module path-major rules and derives the standard full tag
|
|
// and module-local release-note path from a declaration and selection.
|
|
func Derive(selection Selection, declaration []byte, version string) (Identity, error) {
|
|
identity := Identity{Selection: selection, Version: version}
|
|
if selection.RepositoryDir == "" || selection.ModuleDir == "" || selection.ModuleSubdir == "" {
|
|
return identity, fmt.Errorf("incomplete module selection")
|
|
}
|
|
modulePath := modfile.ModulePath(declaration)
|
|
if modulePath == "" {
|
|
return identity, fmt.Errorf("selected module declaration has no valid module directive")
|
|
}
|
|
identity.ModulePath = modulePath
|
|
if err := module.CheckImportPath(modulePath); err != nil {
|
|
return identity, fmt.Errorf("invalid module path %q: %w", modulePath, err)
|
|
}
|
|
_, pathMajor, ok := module.SplitPathVersion(modulePath)
|
|
if !ok {
|
|
return identity, fmt.Errorf("module path %q has an invalid major-version suffix", modulePath)
|
|
}
|
|
if !versioncontract.ValidStableVersion(version) {
|
|
return identity, fmt.Errorf("invalid stable release version %q", version)
|
|
}
|
|
if err := module.CheckPathMajor(version, pathMajor); err != nil {
|
|
return identity, fmt.Errorf("version %q is incompatible with module path %q: %w", version, modulePath, err)
|
|
}
|
|
|
|
tagPrefix := selection.ModuleSubdir
|
|
if strings.HasPrefix(pathMajor, "/") && tagPrefix != "." && path.Base(tagPrefix) == strings.TrimPrefix(pathMajor, "/") {
|
|
tagPrefix = path.Dir(tagPrefix)
|
|
}
|
|
tag := version
|
|
if tagPrefix != "." {
|
|
tag = path.Join(tagPrefix, version)
|
|
}
|
|
notePath := path.Join(selection.ModuleSubdir, "docs", "releases", version+".md")
|
|
return Identity{
|
|
Selection: selection,
|
|
ModulePath: modulePath,
|
|
Version: version,
|
|
Tag: tag,
|
|
NotePath: notePath,
|
|
}, nil
|
|
}
|
|
|
|
func moduleFilePath(moduleSubdir string) string {
|
|
if moduleSubdir == "." {
|
|
return "go.mod"
|
|
}
|
|
return path.Join(moduleSubdir, "go.mod")
|
|
}
|