From 54cc6baf9570f3ef68dff4f7a193db0421a0fa0f Mon Sep 17 00:00:00 2001 From: Eric Rakestraw Date: Sat, 10 Oct 2026 14:20:09 +0000 Subject: [PATCH] Resolve committed module identity from Git objects --- cmd/releasekit/committed_identity_test.go | 71 ++++++++++ cmd/releasekit/main.go | 21 ++- delivery.go | 5 +- docs/consumers/delivery.md | 5 +- docs/internal/overview.md | 4 +- docs/roadmap/remediation.md | 11 +- internal/candidate/check.go | 13 +- internal/delivery/delivery.go | 10 +- internal/git/path.go | 155 ++++++++++++++++++++++ internal/git/source.go | 40 ++++++ internal/releaseidentity/identity.go | 74 ++++++++++- internal/releaseidentity/identity_test.go | 80 +++++++++++ internal/validation/tree.go | 147 +++----------------- 13 files changed, 478 insertions(+), 158 deletions(-) create mode 100644 cmd/releasekit/committed_identity_test.go create mode 100644 internal/git/path.go diff --git a/cmd/releasekit/committed_identity_test.go b/cmd/releasekit/committed_identity_test.go new file mode 100644 index 0000000..47e7b7c --- /dev/null +++ b/cmd/releasekit/committed_identity_test.go @@ -0,0 +1,71 @@ +package main + +import ( + "bytes" + "context" + "errors" + "os" + "os/exec" + "path/filepath" + "testing" + + "gitea.maximumdirect.net/eric/ReleaseKit/cmd/releasekit/internal/cli" +) + +func TestCandidateCheckUsesCommittedDeclarationSymlink(t *testing.T) { + repository := newCLIRepository(t) + if err := os.Rename(filepath.Join(repository, "go.mod"), filepath.Join(repository, "module.txt")); err != nil { + t.Fatal(err) + } + if err := os.Symlink("module.txt", filepath.Join(repository, "go.mod")); err != nil { + t.Fatal(err) + } + writeDeliveryProfile(t, repository, cli.Settings{Product: "Example Product"}) + cliGitRun(t, repository, "add", ".") + cliGitRun(t, repository, "commit", "-m", "Use committed declaration symlink") + var stdout, stderr bytes.Buffer + if status := run(context.Background(), []string{"check", "--repo", repository, "v1.2.3"}, &stdout, &stderr); status != 0 { + t.Fatalf("linked declaration check status=%d stderr=%s", status, stderr.String()) + } +} + +func TestPageOnlyDeliveryUsesCommittedModuleInSparseCheckout(t *testing.T) { + repository := newCLIRepository(t) + const module = "apps/widget" + writeRepositoryFile(t, repository, module+"/go.mod", "module example.com/widget\n\ngo 1.26.0\n") + writeRepositoryFile(t, repository, module+"/docs/releases/v1.2.3.md", "# Example Product v1.2.3\n\nNested release note.\n") + cliGitRun(t, repository, "add", ".") + cliGitRun(t, repository, "commit", "-m", "Add sparse release module") + state := &deliveryServerState{tag: module + "/v1.2.3"} + server := newDeliveryServer(t, state) + defer server.Close() + writeDeliveryProfile(t, repository, cli.Settings{Product: "Example Product", ModuleDir: module, + Delivery: &cli.DeliverySettings{BaseURL: server.URL, Owner: "owner", Repository: "example"}, + }) + state.commit = cliGitOutput(t, repository, "rev-parse", "HEAD") + cliGitRun(t, repository, "tag", state.tag) + cliGitRun(t, repository, "checkout", "--detach", state.tag) + cliGitRun(t, repository, "sparse-checkout", "set", "--cone", "docs") + if _, err := os.Stat(filepath.Join(repository, filepath.FromSlash(module))); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("module fixture is materialized: %v", err) + } + t.Setenv("RELEASEKIT_GITEA_TOKEN", "fixture-token") + gitOnly := t.TempDir() + gitPath, err := exec.LookPath("git") + if err != nil { + t.Fatal(err) + } + if err := os.Symlink(gitPath, filepath.Join(gitOnly, "git")); err != nil { + t.Fatal(err) + } + t.Setenv("PATH", gitOnly) + for _, args := range [][]string{ + {"deliver", "--repo", repository, "v1.2.3"}, + {"ci-deliver", "--repo", repository, "--prepared", "--expected-module", module, "--commit", state.commit, state.tag}, + } { + var stdout, stderr bytes.Buffer + if status := run(context.Background(), args, &stdout, &stderr); status != 0 || state.releaseCreates != 1 || state.assetUploads != 0 { + t.Fatalf("%s sparse delivery status=%d creates=%d uploads=%d stderr=%s", args[0], status, state.releaseCreates, state.assetUploads, stderr.String()) + } + } +} diff --git a/cmd/releasekit/main.go b/cmd/releasekit/main.go index 62db70c..d598cdb 100644 --- a/cmd/releasekit/main.go +++ b/cmd/releasekit/main.go @@ -52,7 +52,7 @@ func run(ctx context.Context, args []string, stdout, stderr io.Writer) int { } request := candidateRequest(invocation, settings, stderr) if invocation.Command == cli.PublishAction { - if err := checkExpectedModule(invocation.Repository, invocation.ExpectedModule, settings.ModuleDir); err != nil { + if err := checkExpectedModule(ctx, invocation.Repository, invocation.ExpectedModule, settings.ModuleDir); err != nil { fmt.Fprintf(stderr, "profile module selection: %v\n", err) return 2 } @@ -220,15 +220,19 @@ func candidateFailureText(err error) string { return err.Error() } -func checkExpectedModule(repositoryDir, expectedModule, profileModule string) error { +func checkExpectedModule(ctx context.Context, repositoryDir, expectedModule, profileModule string) error { if expectedModule == "" { return nil } - expected, err := releaseidentity.ResolveSelection(repositoryDir, expectedModule) + commit, err := git.CaptureHead(ctx, repositoryDir) + if err != nil { + return err + } + expected, err := releaseidentity.ResolveCommittedSelection(ctx, repositoryDir, expectedModule, commit) if err != nil { return fmt.Errorf("normalize expected module %q: %w", expectedModule, err) } - selected, err := releaseidentity.ResolveSelection(repositoryDir, profileModule) + selected, err := releaseidentity.ResolveCommittedSelection(ctx, repositoryDir, profileModule, commit) if err != nil { return fmt.Errorf("normalize profile module %q: %w", profileModule, err) } @@ -282,7 +286,7 @@ func runCIDelivery(ctx context.Context, invocation cli.Invocation, stdout, stder fmt.Fprintf(stderr, "load profile: %v\n", err) return 2 } - if err := checkExpectedModule(invocation.Repository, invocation.ExpectedModule, settings.ModuleDir); err != nil { + if err := checkExpectedModule(ctx, invocation.Repository, invocation.ExpectedModule, settings.ModuleDir); err != nil { fmt.Fprintf(stderr, "profile module selection: %v\n", err) return 2 } @@ -343,7 +347,12 @@ func deliverWithDestination(ctx context.Context, invocation cli.Invocation, sett } if hasTargets { if invocation.AssetsDir != "" { - selection, err := releaseidentity.ResolveSelection(invocation.Repository, settings.ModuleDir) + commit, err := git.CaptureHead(ctx, invocation.Repository) + if err != nil { + fmt.Fprintf(stderr, "capture asset source commit: %v\n", err) + return 1 + } + selection, err := releaseidentity.ResolveCommittedSelection(ctx, invocation.Repository, settings.ModuleDir, commit) if err != nil { fmt.Fprintf(stderr, "resolve asset module directory: %v\n", err) return 1 diff --git a/delivery.go b/delivery.go index e69fa5c..d2a8d02 100644 --- a/delivery.go +++ b/delivery.go @@ -45,7 +45,7 @@ type DeliveryAsset struct { // page-only delivery when both are nil. Build with no targets, or an empty // Prebuilt result, also performs page-only delivery. type DeliveryRequest struct { - RepositoryDir string + RepositoryDir string // directory within the tagged source Git checkout. // ModuleDir selects a repository-relative module directory; empty selects // the repository root. ModuleDir string @@ -93,6 +93,9 @@ type DeliveryResult struct { // requests are compiled into temporary storage that Deliver removes on return. // Prebuilt artifacts remain caller-owned and are not removed. Both paths // validate source identity and artifact contents before hosted writes. +// Module selection and declaration links resolve from the captured commit. +// Page-only delivery can use a sparse checkout with available committed objects; +// managed builds require their selected source files in the working tree. func Deliver(ctx context.Context, request DeliveryRequest) (DeliveryResult, error) { result := DeliveryResult{Version: request.Version, State: DeliveryNotAttempted} destination := gitea.Destination{ diff --git a/docs/consumers/delivery.md b/docs/consumers/delivery.md index 0d59edb..5fc71e0 100644 --- a/docs/consumers/delivery.md +++ b/docs/consumers/delivery.md @@ -86,7 +86,10 @@ For nested modules, the note comes from that module's committed `docs/releases/.md`, and the hosted page is keyed by its complete tag. With no build targets, delivery is page-only: no builder, version smoke check, -receipt, binary asset, or checksum manifest is involved. With selected targets, +receipt, binary asset, or checksum manifest is involved. Committed module +directories and declaration links can be resolved from a sparse checkout; +page-only delivery needs the committed objects and note. Managed builds require +the selected source files to be materialized. With selected targets, one `deliver` invocation builds the tagged source through the facade into temporary storage and delivers the result. To retain artifacts for a retry or separate credentials between build and delivery, use two invocations: diff --git a/docs/internal/overview.md b/docs/internal/overview.md index 17a89c8..2e3deef 100644 --- a/docs/internal/overview.md +++ b/docs/internal/overview.md @@ -40,13 +40,13 @@ reports these implemented packages: | `internal/build` | Resolved-identity builds, normalized settings, selected-module compilation, version injection, external native smoke output, and artifact digests. | | `internal/candidate` | Stable-version, note, repository-state, candidate capture, and post-validation checks. | | `internal/delivery` | Captured-commit module identity and note selection, literal-tag checkout checks, prebuilt identity binding, managed builds in disposable storage outside the checkout, structural artifact declaration, manifest assembly, one local digest-and-size preflight before hosted requests, and delivery coordination. | -| `internal/git` | Git command boundary, committed-tree reads, checkout-commit capture, lazy local-only batched blob reads, push-destination resolution, exact ref inspection, tagged checkout preparation, and tag operations, including captured-endpoint verification during recovery. | +| `internal/git` | Git command boundary, committed-tree reads and shared reference traversal, checkout-root and commit capture, lazy local-only batched blob reads, push-destination resolution, exact ref inspection, tagged checkout preparation, and tag operations, including captured-endpoint verification during recovery. | | `internal/gitea` | Gitea API transport, full-tag hosted release pages, asset inspection, manifests, and uploads. | | `internal/gotool` | Operation-local Go configuration discovery and reuse, shared native-validation and static-build environments, and network-capable dependency preparation; see [Go command environments](gotool.md). | | `internal/tempdir` | Disposable system temporary storage outside source worktrees. | | `internal/process` | Cancellable child-process execution, including owned stdin/stdout pipes for serialized exchanges, with per-command environment overlays and removals. | | `internal/preparation` | Coordination for expected-commit tag checkout and Go module dependency preparation. | -| `internal/releaseidentity` | Shared module selection, declaration reading, and semantic-version-to-tag derivation for committed and working-tree operations. | +| `internal/releaseidentity` | Committed-object and working-tree module selection, declaration reading, and semantic-version-to-tag derivation. | | `internal/validation` | Shared Go validation with optional race, full-vet, and static package-compilation target selections, formatting, workspace-file and filesystem-replacement checks, per-blob size limits, and selected Markdown destination and fragment checking; `internal/candidate` retains capture, callback sequencing, and local-state rechecks. | | `internal/publish` | Publication guards, one validation run, annotated tag creation, push, and recovery state. | | `internal/runtimeinfo` | Injected version selection and cached fallback metadata lookup for the runtime facade. | diff --git a/docs/roadmap/remediation.md b/docs/roadmap/remediation.md index 1201259..aca2409 100644 --- a/docs/roadmap/remediation.md +++ b/docs/roadmap/remediation.md @@ -228,7 +228,16 @@ major identities for the selected release convention. ## Issue 7: Resolve committed identity from committed material (P2) -Status: open. Category: declaration-source divergence and added prerequisites. +Status: complete. Category: declaration-source divergence and added prerequisites. + +**Completion:** Shared Git reference traversal now serves validation and committed +module identity. Committed selection follows directory and declaration links +using targeted object reads; working-tree builds retain filesystem selection. +CLI mapping and prebuilt path selection use committed directory facts. Regression +tests cover captured links, changed working declarations, cycles, checkout +boundaries, unavailable blobs, cancellation, and ordinary/prepared CI page-only +delivery from a sparse checkout without Go. The complete offline suite and +compiled public API/dependency checks pass. **Evidence:** diff --git a/internal/candidate/check.go b/internal/candidate/check.go index 4f6141e..603049e 100644 --- a/internal/candidate/check.go +++ b/internal/candidate/check.go @@ -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) diff --git a/internal/delivery/delivery.go b/internal/delivery/delivery.go index 7333f60..b7e808e 100644 --- a/internal/delivery/delivery.go +++ b/internal/delivery/delivery.go @@ -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) } diff --git a/internal/git/path.go b/internal/git/path.go new file mode 100644 index 0000000..6c23631 --- /dev/null +++ b/internal/git/path.go @@ -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, "/") +} diff --git a/internal/git/source.go b/internal/git/source.go index 394001c..9b297de 100644 --- a/internal/git/source.go +++ b/internal/git/source.go @@ -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) { diff --git a/internal/releaseidentity/identity.go b/internal/releaseidentity/identity.go index 2c8cfba..c45a206 100644 --- a/internal/releaseidentity/identity.go +++ b/internal/releaseidentity/identity.go @@ -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) } diff --git a/internal/releaseidentity/identity_test.go b/internal/releaseidentity/identity_test.go index ed956d4..9a335c4 100644 --- a/internal/releaseidentity/identity_test.go +++ b/internal/releaseidentity/identity_test.go @@ -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") diff --git a/internal/validation/tree.go b/internal/validation/tree.go index 4bdcef4..792dd86 100644 --- a/internal/validation/tree.go +++ b/internal/validation/tree.go @@ -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) }