Resolve committed module identity from Git objects
This commit is contained in:
71
cmd/releasekit/committed_identity_test.go
Normal file
71
cmd/releasekit/committed_identity_test.go
Normal file
@@ -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())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -52,7 +52,7 @@ func run(ctx context.Context, args []string, stdout, stderr io.Writer) int {
|
|||||||
}
|
}
|
||||||
request := candidateRequest(invocation, settings, stderr)
|
request := candidateRequest(invocation, settings, stderr)
|
||||||
if invocation.Command == cli.PublishAction {
|
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)
|
fmt.Fprintf(stderr, "profile module selection: %v\n", err)
|
||||||
return 2
|
return 2
|
||||||
}
|
}
|
||||||
@@ -220,15 +220,19 @@ func candidateFailureText(err error) string {
|
|||||||
return err.Error()
|
return err.Error()
|
||||||
}
|
}
|
||||||
|
|
||||||
func checkExpectedModule(repositoryDir, expectedModule, profileModule string) error {
|
func checkExpectedModule(ctx context.Context, repositoryDir, expectedModule, profileModule string) error {
|
||||||
if expectedModule == "" {
|
if expectedModule == "" {
|
||||||
return nil
|
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 {
|
if err != nil {
|
||||||
return fmt.Errorf("normalize expected module %q: %w", expectedModule, err)
|
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 {
|
if err != nil {
|
||||||
return fmt.Errorf("normalize profile module %q: %w", profileModule, err)
|
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)
|
fmt.Fprintf(stderr, "load profile: %v\n", err)
|
||||||
return 2
|
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)
|
fmt.Fprintf(stderr, "profile module selection: %v\n", err)
|
||||||
return 2
|
return 2
|
||||||
}
|
}
|
||||||
@@ -343,7 +347,12 @@ func deliverWithDestination(ctx context.Context, invocation cli.Invocation, sett
|
|||||||
}
|
}
|
||||||
if hasTargets {
|
if hasTargets {
|
||||||
if invocation.AssetsDir != "" {
|
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 {
|
if err != nil {
|
||||||
fmt.Fprintf(stderr, "resolve asset module directory: %v\n", err)
|
fmt.Fprintf(stderr, "resolve asset module directory: %v\n", err)
|
||||||
return 1
|
return 1
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ type DeliveryAsset struct {
|
|||||||
// page-only delivery when both are nil. Build with no targets, or an empty
|
// page-only delivery when both are nil. Build with no targets, or an empty
|
||||||
// Prebuilt result, also performs page-only delivery.
|
// Prebuilt result, also performs page-only delivery.
|
||||||
type DeliveryRequest struct {
|
type DeliveryRequest struct {
|
||||||
RepositoryDir string
|
RepositoryDir string // directory within the tagged source Git checkout.
|
||||||
// ModuleDir selects a repository-relative module directory; empty selects
|
// ModuleDir selects a repository-relative module directory; empty selects
|
||||||
// the repository root.
|
// the repository root.
|
||||||
ModuleDir string
|
ModuleDir string
|
||||||
@@ -93,6 +93,9 @@ type DeliveryResult struct {
|
|||||||
// requests are compiled into temporary storage that Deliver removes on return.
|
// requests are compiled into temporary storage that Deliver removes on return.
|
||||||
// Prebuilt artifacts remain caller-owned and are not removed. Both paths
|
// Prebuilt artifacts remain caller-owned and are not removed. Both paths
|
||||||
// validate source identity and artifact contents before hosted writes.
|
// 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) {
|
func Deliver(ctx context.Context, request DeliveryRequest) (DeliveryResult, error) {
|
||||||
result := DeliveryResult{Version: request.Version, State: DeliveryNotAttempted}
|
result := DeliveryResult{Version: request.Version, State: DeliveryNotAttempted}
|
||||||
destination := gitea.Destination{
|
destination := gitea.Destination{
|
||||||
|
|||||||
@@ -86,7 +86,10 @@ For nested modules, the note comes from that module's committed
|
|||||||
`docs/releases/<version>.md`, and the hosted page is keyed by its complete tag.
|
`docs/releases/<version>.md`, and the hosted page is keyed by its complete tag.
|
||||||
|
|
||||||
With no build targets, delivery is page-only: no builder, version smoke check,
|
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
|
one `deliver` invocation builds the tagged source through the facade into
|
||||||
temporary storage and delivers the result. To retain artifacts for a retry or
|
temporary storage and delivers the result. To retain artifacts for a retry or
|
||||||
separate credentials between build and delivery, use two invocations:
|
separate credentials between build and delivery, use two invocations:
|
||||||
|
|||||||
@@ -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/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/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/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/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/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/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/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/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/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/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. |
|
| `internal/runtimeinfo` | Injected version selection and cached fallback metadata lookup for the runtime facade. |
|
||||||
|
|||||||
@@ -228,7 +228,16 @@ major identities for the selected release convention.
|
|||||||
|
|
||||||
## Issue 7: Resolve committed identity from committed material (P2)
|
## 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:**
|
**Evidence:**
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
return captured, fmt.Errorf("release candidate worktree and index must be clean:\n%s", state.status)
|
||||||
}
|
}
|
||||||
|
|
||||||
identitySelection, err := releaseidentity.ResolveSelection(repositoryDir, request.ModuleDir)
|
identity, err := releaseidentity.FromCommitted(ctx, repositoryDir, request.ModuleDir, request.Version, state.commit)
|
||||||
if err != nil {
|
captured.ModuleDir = identity.ModuleDir
|
||||||
return captured, fmt.Errorf("resolve selected module: %w", err)
|
captured.ModulePath = identity.ModulePath
|
||||||
}
|
captured.Tag = identity.Tag
|
||||||
captured.RepositoryDir = identitySelection.RepositoryDir
|
|
||||||
captured.ModuleDir = identitySelection.ModuleDir
|
|
||||||
identity, err := releaseidentity.FromCommittedSelection(ctx, identitySelection, request.Version, state.commit)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return captured, fmt.Errorf("resolve selected module release identity: %w", err)
|
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)
|
note, err := git.ReadCommitFile(ctx, identity.RepositoryDir, state.commit, identity.NotePath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return captured, fmt.Errorf("read committed release note %q: %w", identity.NotePath, err)
|
return captured, fmt.Errorf("read committed release note %q: %w", identity.NotePath, err)
|
||||||
|
|||||||
@@ -72,17 +72,15 @@ func Deliver(ctx context.Context, request Request) (Result, error) {
|
|||||||
return result, fmt.Errorf("resolve repository directory: %w", err)
|
return result, fmt.Errorf("resolve repository directory: %w", err)
|
||||||
}
|
}
|
||||||
result.RepositoryDir = repositoryDir
|
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)
|
commit, err := git.CaptureHead(ctx, repositoryDir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return result, err
|
return result, err
|
||||||
}
|
}
|
||||||
result.CandidateCommit = commit
|
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 {
|
if err != nil {
|
||||||
return result, fmt.Errorf("resolve committed delivery identity: %w", err)
|
return result, fmt.Errorf("resolve committed delivery identity: %w", err)
|
||||||
}
|
}
|
||||||
|
|||||||
155
internal/git/path.go
Normal file
155
internal/git/path.go
Normal 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, "/")
|
||||||
|
}
|
||||||
@@ -123,6 +123,46 @@ func ReadCommitFile(ctx context.Context, repositoryDir, commit, name string) ([]
|
|||||||
return []byte(content), nil
|
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
|
// ReadTree enumerates all entries in a commit and records blob sizes. Paths
|
||||||
// are NUL-delimited by Git so whitespace in a filename is preserved.
|
// are NUL-delimited by Git so whitespace in a filename is preserved.
|
||||||
func ReadTree(ctx context.Context, repositoryDir, commit string) ([]TreeEntry, error) {
|
func ReadTree(ctx context.Context, repositoryDir, commit string) ([]TreeEntry, error) {
|
||||||
|
|||||||
@@ -75,22 +75,84 @@ func ResolveSelection(repositoryDir, moduleDir string) (Selection, error) {
|
|||||||
// FromCommitted derives identity from the selected module declaration at a
|
// FromCommitted derives identity from the selected module declaration at a
|
||||||
// captured commit. The selected module must contain its own go.mod.
|
// captured commit. The selected module must contain its own go.mod.
|
||||||
func FromCommitted(ctx context.Context, repositoryDir, moduleDir, version, commit string) (Identity, error) {
|
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 {
|
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
|
// FromCommittedSelection derives identity from a previously resolved module
|
||||||
// selection and the selected module declaration at a captured commit.
|
// selection and the selected module declaration at a captured commit.
|
||||||
func FromCommittedSelection(ctx context.Context, selection Selection, version, commit string) (Identity, error) {
|
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 {
|
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 {
|
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)
|
return Derive(selection, declaration, version)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package releaseidentity
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
"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) {
|
func TestSelectedNestedModuleDoesNotRequireRootDeclaration(t *testing.T) {
|
||||||
repository := t.TempDir()
|
repository := t.TempDir()
|
||||||
moduleDir := filepath.Join(repository, "nested")
|
moduleDir := filepath.Join(repository, "nested")
|
||||||
|
|||||||
@@ -310,137 +310,32 @@ func (tree *CandidateTree) Entry(path string) (git.TreeEntry, bool) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// DestinationKind classifies a committed local destination.
|
// DestinationKind classifies a committed local destination.
|
||||||
type DestinationKind string
|
type DestinationKind = git.DestinationKind
|
||||||
|
|
||||||
const (
|
const (
|
||||||
DestinationEntry DestinationKind = "entry"
|
DestinationEntry = git.DestinationEntry
|
||||||
DestinationDirectory DestinationKind = "directory"
|
DestinationDirectory = git.DestinationDirectory
|
||||||
DestinationMissing DestinationKind = "missing"
|
DestinationMissing = git.DestinationMissing
|
||||||
DestinationOutsideCheckout DestinationKind = "outside-checkout"
|
DestinationOutsideCheckout = git.DestinationOutsideCheckout
|
||||||
DestinationSubmodule DestinationKind = "submodule"
|
DestinationSubmodule = git.DestinationSubmodule
|
||||||
DestinationSymlinkCycle DestinationKind = "symlink-cycle"
|
DestinationSymlinkCycle = git.DestinationSymlinkCycle
|
||||||
)
|
)
|
||||||
|
|
||||||
// Destination records the resolved identity and its classification.
|
type Destination = git.Destination
|
||||||
type Destination struct {
|
|
||||||
Kind DestinationKind
|
|
||||||
Path string
|
|
||||||
}
|
|
||||||
|
|
||||||
// ResolveDestination resolves local destinations using the captured Git tree.
|
// ResolveDestination uses shared committed reference traversal with the tree's
|
||||||
// Relative destinations start at the source directory; absolute destinations
|
// indexed metadata and scoped symlink reader.
|
||||||
// within the repository directory map back to committed paths.
|
|
||||||
func (tree *CandidateTree) ResolveDestination(ctx context.Context, sourcePath, destination string) (Destination, error) {
|
func (tree *CandidateTree) ResolveDestination(ctx context.Context, sourcePath, destination string) (Destination, error) {
|
||||||
if err := ctx.Err(); err != nil {
|
resolver := git.PathResolver{
|
||||||
return Destination{}, err
|
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)
|
return resolver.ResolveDestination(ctx, sourcePath, destination)
|
||||||
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, "/")
|
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user