Resolve committed module identity from Git objects

This commit is contained in:
2026-10-10 14:20:09 +00:00
parent 7eebbbbfc3
commit 54cc6baf95
13 changed files with 478 additions and 158 deletions

View File

@@ -91,18 +91,13 @@ func Capture(ctx context.Context, request Request) (Candidate, error) {
return captured, fmt.Errorf("release candidate worktree and index must be clean:\n%s", state.status)
}
identitySelection, err := releaseidentity.ResolveSelection(repositoryDir, request.ModuleDir)
if err != nil {
return captured, fmt.Errorf("resolve selected module: %w", err)
}
captured.RepositoryDir = identitySelection.RepositoryDir
captured.ModuleDir = identitySelection.ModuleDir
identity, err := releaseidentity.FromCommittedSelection(ctx, identitySelection, request.Version, state.commit)
identity, err := releaseidentity.FromCommitted(ctx, repositoryDir, request.ModuleDir, request.Version, state.commit)
captured.ModuleDir = identity.ModuleDir
captured.ModulePath = identity.ModulePath
captured.Tag = identity.Tag
if err != nil {
return captured, fmt.Errorf("resolve selected module release identity: %w", err)
}
captured.ModulePath = identity.ModulePath
captured.Tag = identity.Tag
note, err := git.ReadCommitFile(ctx, identity.RepositoryDir, state.commit, identity.NotePath)
if err != nil {
return captured, fmt.Errorf("read committed release note %q: %w", identity.NotePath, err)

View File

@@ -72,17 +72,15 @@ func Deliver(ctx context.Context, request Request) (Result, error) {
return result, fmt.Errorf("resolve repository directory: %w", err)
}
result.RepositoryDir = repositoryDir
selection, err := releaseidentity.ResolveSelection(repositoryDir, request.ModuleDir)
if err != nil {
return result, fmt.Errorf("resolve delivery module: %w", err)
}
result.ModuleDir = selection.ModuleDir
commit, err := git.CaptureHead(ctx, repositoryDir)
if err != nil {
return result, err
}
result.CandidateCommit = commit
identity, err := releaseidentity.FromCommittedSelection(ctx, selection, request.Version, commit)
identity, err := releaseidentity.FromCommitted(ctx, repositoryDir, request.ModuleDir, request.Version, commit)
result.ModuleDir = identity.ModuleDir
result.ModulePath = identity.ModulePath
result.Tag = identity.Tag
if err != nil {
return result, fmt.Errorf("resolve committed delivery identity: %w", err)
}

155
internal/git/path.go Normal file
View File

@@ -0,0 +1,155 @@
package git
import (
"context"
"fmt"
"path"
"path/filepath"
"strings"
)
// PathResolver traverses committed paths, sharing reference rules between
// release identity and validation. Lookups include both files and directories.
type PathResolver struct {
RepositoryDir string
Lookup func(context.Context, string) (TreeEntry, bool, error)
ReadLink func(context.Context, TreeEntry) (string, error)
}
// 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 local destinations using the captured Git tree.
// Relative destinations start at the source directory; absolute destinations
// within the repository directory map back to committed paths.
func (resolver PathResolver) ResolveDestination(ctx context.Context, sourcePath, destination string) (Destination, error) {
if err := ctx.Err(); err != nil {
return Destination{}, err
}
directory := path.Dir(sourcePath)
if path.IsAbs(destination) {
relative, inside, err := resolver.relativeAbsoluteDestination(destination)
if err != nil {
return Destination{}, err
}
if !inside {
return Destination{Kind: DestinationOutsideCheckout, Path: destination}, nil
}
directory, destination = ".", relative
}
return resolver.resolveDestination(ctx, directory, strings.Split(destination, "/"), make(map[string]bool))
}
func (resolver PathResolver) 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, err := resolver.Lookup(ctx, partial)
if err != nil {
return Destination{}, err
}
if !exists {
return Destination{Kind: DestinationMissing, Path: unresolvedPath(partial, remaining)}, nil
}
if entry.Type == "tree" {
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 := resolver.ReadLink(ctx, entry)
if err != nil {
return Destination{}, fmt.Errorf("read committed symlink %q: %w", partial, err)
}
target := linkText
targetDirectory := directory
if path.IsAbs(target) {
relative, inside, err := resolver.relativeAbsoluteDestination(target)
if err != nil {
return Destination{}, err
}
if !inside {
return Destination{Kind: DestinationOutsideCheckout, Path: unresolvedPath(target, remaining)}, nil
}
targetDirectory, target = ".", relative
}
active[partial] = true
destination, err := resolver.resolveDestination(ctx, targetDirectory, strings.Split(target, "/"), active)
delete(active, partial)
if err != nil || len(remaining) == 0 {
return destination, err
}
switch destination.Kind {
case DestinationDirectory:
return resolver.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
}
func (resolver PathResolver) relativeAbsoluteDestination(destination string) (string, bool, error) {
root, err := filepath.Abs(resolver.RepositoryDir)
if err != nil {
return "", false, fmt.Errorf("resolve candidate repository directory: %w", err)
}
root = filepath.ToSlash(root)
if destination == root {
return ".", true, nil
}
// Preserve the suffix for component traversal, including symlinks before '..'.
relative, inside := strings.CutPrefix(destination, strings.TrimSuffix(root, "/")+"/")
return relative, inside, 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, "/")
}

View File

@@ -123,6 +123,46 @@ func ReadCommitFile(ctx context.Context, repositoryDir, commit, name string) ([]
return []byte(content), nil
}
// CommittedPaths resolves individual paths from captured objects. It requests
// only metadata and link contents needed for traversal.
func CommittedPaths(repositoryDir, commit string) PathResolver {
metadata := make(map[string]TreeEntry)
return PathResolver{
RepositoryDir: repositoryDir,
Lookup: func(ctx context.Context, name string) (TreeEntry, bool, error) {
if err := ctx.Err(); err != nil {
return TreeEntry{}, false, err
}
if entry, seen := metadata[name]; seen {
return entry, entry.ObjectID != "", nil
}
if !validObjectID(commit) || name == "" || path.IsAbs(name) || path.Clean(name) != name || name == ".." || strings.HasPrefix(name, "../") {
return TreeEntry{}, false, fmt.Errorf("invalid committed path identity %q at %q", name, commit)
}
output, err := readLocalObjects(ctx, repositoryDir, nil, "--literal-pathspecs", "ls-tree", "-z", "--full-tree", commit, "--", name)
if err != nil {
return TreeEntry{}, false, fmt.Errorf("inspect committed path %q: %w", name, err)
}
entries, err := parseTreeEntries(output)
if err != nil {
return TreeEntry{}, false, err
}
for _, entry := range entries {
if entry.Path == name {
metadata[name] = entry
return entry, true, nil
}
}
metadata[name] = TreeEntry{}
return TreeEntry{}, false, nil
},
ReadLink: func(ctx context.Context, entry TreeEntry) (string, error) {
contents, err := ReadBlob(ctx, repositoryDir, entry.ObjectID)
return string(contents), err
},
}
}
// ReadTree enumerates all entries in a commit and records blob sizes. Paths
// are NUL-delimited by Git so whitespace in a filename is preserved.
func ReadTree(ctx context.Context, repositoryDir, commit string) ([]TreeEntry, error) {

View File

@@ -75,22 +75,84 @@ func ResolveSelection(repositoryDir, moduleDir string) (Selection, error) {
// 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) {
selection, err := ResolveSelection(repositoryDir, moduleDir)
resolver, err := committedPaths(repositoryDir, commit)
if err != nil {
return Identity{}, err
return Identity{Version: version}, err
}
return FromCommittedSelection(ctx, selection, version, commit)
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)
return identity, fmt.Errorf("resolve committed release identity: %w", err)
}
declaration, err := git.ReadCommitFile(ctx, selection.RepositoryDir, commit, moduleFilePath(selection.ModuleSubdir))
declarationPath, err := resolver.ResolveDestination(ctx, "go.mod", moduleFilePath(selection.ModuleSubdir))
if err != nil {
return Identity{}, fmt.Errorf("read selected module declaration at %s: %w", commit, err)
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)
}

View File

@@ -2,6 +2,7 @@ package releaseidentity
import (
"context"
"errors"
"os"
"os/exec"
"path/filepath"
@@ -134,6 +135,85 @@ func TestCommittedAndWorkingTreeDeclarationsUseTheirOwnSources(t *testing.T) {
}
}
func TestCommittedIdentityFollowsCapturedDeclarationAndDirectoryLinks(t *testing.T) {
repository := t.TempDir()
gitIdentityRun(t, repository, "init", "-q")
gitIdentityRun(t, repository, "config", "user.name", "ReleaseKit test")
gitIdentityRun(t, repository, "config", "user.email", "releasekit@example.invalid")
moduleDir := filepath.Join(repository, "nested")
if err := os.Mkdir(moduleDir, 0o755); err != nil {
t.Fatal(err)
}
declaration := filepath.Join(repository, "module.txt")
if err := os.WriteFile(declaration, []byte("module example.com/committed\n"), 0o644); err != nil {
t.Fatal(err)
}
if err := os.Symlink("../module.txt", filepath.Join(moduleDir, "go.mod")); err != nil {
t.Fatal(err)
}
if err := os.Symlink("nested", filepath.Join(repository, "selected")); err != nil {
t.Fatal(err)
}
gitIdentityRun(t, repository, "add", ".")
gitIdentityRun(t, repository, "commit", "-qm", "Add linked declaration")
commit := strings.TrimSpace(gitIdentityOutput(t, repository, "rev-parse", "HEAD"))
if err := os.WriteFile(declaration, []byte("module example.com/working\n"), 0o644); err != nil {
t.Fatal(err)
}
committed, err := FromCommitted(t.Context(), repository, "selected", "v1.2.3", commit)
if err != nil || committed.ModulePath != "example.com/committed" || committed.ModuleDir != moduleDir || committed.Tag != "nested/v1.2.3" {
t.Fatalf("committed linked identity = %#v, error = %v", committed, err)
}
working, err := FromWorkingTree(t.Context(), repository, "selected", "v1.2.3")
if err != nil || working.ModulePath != "example.com/working" || working.ModuleDir != moduleDir {
t.Fatalf("working linked identity = %#v, error = %v", working, err)
}
ctx, cancel := context.WithCancel(t.Context())
cancel()
if _, err := FromCommitted(ctx, repository, "selected", "v1.2.3", commit); !errors.Is(err, context.Canceled) {
t.Fatalf("canceled committed identity error = %v", err)
}
}
func TestCommittedIdentityPreservesReferenceFailures(t *testing.T) {
for _, mode := range []string{"cycle", "outside", "missing", "unavailable blob"} {
t.Run(mode, func(t *testing.T) {
repository := t.TempDir()
gitIdentityRun(t, repository, "init", "-q")
gitIdentityRun(t, repository, "config", "user.name", "ReleaseKit test")
gitIdentityRun(t, repository, "config", "user.email", "releasekit@example.invalid")
target, wantCause := "go.mod", "symlink-cycle"
switch mode {
case "outside":
target, wantCause = "../module.txt", "outside-checkout"
case "missing":
target, wantCause = "missing.mod", "missing"
case "unavailable blob":
target, wantCause = "module.txt", "read selected module declaration"
if err := os.WriteFile(filepath.Join(repository, target), []byte("module example.com/linked\n"), 0o644); err != nil {
t.Fatal(err)
}
}
if err := os.Symlink(target, filepath.Join(repository, "go.mod")); err != nil {
t.Fatal(err)
}
gitIdentityRun(t, repository, "add", ".")
gitIdentityRun(t, repository, "commit", "-qm", "Add reference fixture")
commit := strings.TrimSpace(gitIdentityOutput(t, repository, "rev-parse", "HEAD"))
if mode == "unavailable blob" {
object := strings.TrimSpace(gitIdentityOutput(t, repository, "rev-parse", "HEAD:module.txt"))
if err := os.Remove(filepath.Join(repository, ".git", "objects", object[:2], object[2:])); err != nil {
t.Fatal(err)
}
}
identity, err := FromCommitted(t.Context(), repository, ".", "v1.2.3", commit)
if err == nil || !strings.Contains(err.Error(), wantCause) || identity.RepositoryDir != repository || identity.ModuleDir != repository {
t.Fatalf("identity = %#v, error = %v; want %s", identity, err, wantCause)
}
})
}
}
func TestSelectedNestedModuleDoesNotRequireRootDeclaration(t *testing.T) {
repository := t.TempDir()
moduleDir := filepath.Join(repository, "nested")

View File

@@ -310,137 +310,32 @@ func (tree *CandidateTree) Entry(path string) (git.TreeEntry, bool) {
}
// DestinationKind classifies a committed local destination.
type DestinationKind string
type DestinationKind = git.DestinationKind
const (
DestinationEntry DestinationKind = "entry"
DestinationDirectory DestinationKind = "directory"
DestinationMissing DestinationKind = "missing"
DestinationOutsideCheckout DestinationKind = "outside-checkout"
DestinationSubmodule DestinationKind = "submodule"
DestinationSymlinkCycle DestinationKind = "symlink-cycle"
DestinationEntry = git.DestinationEntry
DestinationDirectory = git.DestinationDirectory
DestinationMissing = git.DestinationMissing
DestinationOutsideCheckout = git.DestinationOutsideCheckout
DestinationSubmodule = git.DestinationSubmodule
DestinationSymlinkCycle = git.DestinationSymlinkCycle
)
// Destination records the resolved identity and its classification.
type Destination struct {
Kind DestinationKind
Path string
}
type Destination = git.Destination
// ResolveDestination resolves local destinations using the captured Git tree.
// Relative destinations start at the source directory; absolute destinations
// within the repository directory map back to committed paths.
// 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) {
if err := ctx.Err(); err != nil {
return Destination{}, err
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,
}
directory := path.Dir(sourcePath)
if path.IsAbs(destination) {
relative, inside, err := tree.relativeAbsoluteDestination(destination)
if err != nil {
return Destination{}, err
}
if !inside {
return Destination{Kind: DestinationOutsideCheckout, Path: destination}, nil
}
directory, destination = ".", relative
}
return tree.resolveDestination(ctx, directory, 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
}
entry, _ := tree.Entry(partial)
linkText, err := tree.readSymlinkTarget(ctx, entry)
if err != nil {
return Destination{}, fmt.Errorf("read committed symlink %q: %w", partial, err)
}
target := linkText
targetDirectory := directory
if path.IsAbs(target) {
relative, inside, err := tree.relativeAbsoluteDestination(target)
if err != nil {
return Destination{}, err
}
if !inside {
return Destination{Kind: DestinationOutsideCheckout, Path: unresolvedPath(target, remaining)}, nil
}
targetDirectory, target = ".", relative
}
active[partial] = true
destination, err := tree.resolveDestination(ctx, targetDirectory, 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
}
func (tree *CandidateTree) relativeAbsoluteDestination(destination string) (string, bool, error) {
root, err := filepath.Abs(tree.repositoryDir)
if err != nil {
return "", false, fmt.Errorf("resolve candidate repository directory: %w", err)
}
root = filepath.ToSlash(root)
if destination == root {
return ".", true, nil
}
// Preserve the suffix for component traversal, including symlinks before '..'.
relative, inside := strings.CutPrefix(destination, strings.TrimSuffix(root, "/")+"/")
return relative, inside, 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, "/")
return resolver.ResolveDestination(ctx, sourcePath, destination)
}