Resolve source operations from the Git checkout root

This commit is contained in:
2026-10-10 14:03:57 +00:00
parent 750632f80e
commit 7555d3e6c9
11 changed files with 164 additions and 13 deletions

View File

@@ -6,6 +6,7 @@ import (
"gitea.maximumdirect.net/eric/ReleaseKit/internal/artifact"
internalbuild "gitea.maximumdirect.net/eric/ReleaseKit/internal/build"
"gitea.maximumdirect.net/eric/ReleaseKit/internal/git"
"gitea.maximumdirect.net/eric/ReleaseKit/internal/releaseidentity"
)
@@ -24,7 +25,7 @@ type BuildSpec struct {
// BuildRequest contains the source and output settings for Build.
type BuildRequest struct {
RepositoryDir string // source repository whose current HEAD is recorded.
RepositoryDir string // directory within the source Git checkout whose HEAD is recorded.
// ModuleDir selects a repository-relative module directory; empty selects
// the repository root.
ModuleDir string
@@ -71,7 +72,11 @@ type BuildResult struct {
// Errors return partial build facts in the result.
func Build(ctx context.Context, request BuildRequest) (BuildResult, error) {
public := BuildResult{Version: request.Version}
identity, err := releaseidentity.FromWorkingTree(ctx, request.RepositoryDir, request.ModuleDir, request.Version)
repositoryDir, err := git.RepositoryRoot(ctx, request.RepositoryDir)
if err != nil {
return public, err
}
identity, err := releaseidentity.FromWorkingTree(ctx, repositoryDir, request.ModuleDir, request.Version)
if err != nil {
return public, err
}

View File

@@ -33,12 +33,14 @@ type Candidate struct {
// local candidate. Remote is retained for publication operations; Check makes
// no remote availability claim.
type ReleaseRequest struct {
// RepositoryDir names a directory within the source Git checkout. Operations
// resolve its root before applying repository-relative selections.
RepositoryDir string
// ModuleDir selects a repository-relative module directory; empty selects
// the repository root.
ModuleDir string
Product string
Version string // stable semantic version, independent of the derived Git tag.
Product string
Version string // stable semantic version, independent of the derived Git tag.
Remote string
// ExpectedPushURL optionally requires Publish's resolved push URL to match
// this string exactly before validation or tag creation. Check ignores this

View File

@@ -14,6 +14,7 @@ import (
"gitea.maximumdirect.net/eric/ReleaseKit"
"gitea.maximumdirect.net/eric/ReleaseKit/buildinfo"
"gitea.maximumdirect.net/eric/ReleaseKit/cmd/releasekit/internal/cli"
"gitea.maximumdirect.net/eric/ReleaseKit/internal/git"
"gitea.maximumdirect.net/eric/ReleaseKit/internal/releaseidentity"
)
@@ -30,6 +31,13 @@ func run(ctx context.Context, args []string, stdout, stderr io.Writer) int {
fmt.Fprintf(stderr, "%v\n%s\n", err, cli.Usage)
return 2
}
if invocation.Command != cli.VersionCommand {
invocation.Repository, err = git.RepositoryRoot(ctx, invocation.Repository)
if err != nil {
fmt.Fprintf(stderr, "%v\n", err)
return 1
}
}
switch invocation.Command {
case cli.VersionCommand:

View File

@@ -0,0 +1,25 @@
package main
import (
"os/exec"
"path/filepath"
"strings"
"testing"
)
func TestNativeCheckFromChildUsesRootProfileAndSuite(t *testing.T) {
binary := buildWorkflowCLI(t, cliRepositoryRoot(t))
repository := newCLIRepository(t)
writeRepositoryFile(t, repository, "candidate_test.go", "package candidate\nimport \"testing\"\nfunc TestFixture(t *testing.T) { t.Fatal(\"root suite failure\") }\n")
writeRepositoryFile(t, repository, "releasekit.json", `{"product":"Example Product"}`)
writeRepositoryFile(t, repository, "apps/widget/go.mod", "module example.com/widget\n\ngo 1.26.0\n")
writeRepositoryFile(t, repository, "apps/widget/widget_test.go", "package widget\nimport \"testing\"\nfunc TestWidget(t *testing.T) {}\n")
cliGitRun(t, repository, "add", ".")
cliGitRun(t, repository, "commit", "-m", "Add failing root and passing child")
command := exec.Command(binary, "check", "v1.2.3")
command.Dir = filepath.Join(repository, "apps", "widget")
output, err := command.CombinedOutput()
if exit, ok := err.(*exec.ExitError); !ok || exit.ExitCode() != 1 || !strings.Contains(string(output), "root suite failure") || !strings.Contains(string(output), "module=example.com/cli-candidate") {
t.Fatalf("child invocation error = %v, output = %s", err, output)
}
}

View File

@@ -21,8 +21,9 @@ go run ./cmd/releasekit check [--repo DIR] [--config FILE] VERSION
```
`VERSION` must use stable `vMAJOR.MINOR.PATCH` syntax. Options must precede the
version. The repository defaults to the current directory, and the profile
defaults to `<repository>/releasekit.json`. A successful command prints the
version. The repository argument defaults to the current directory. ReleaseKit
resolves its containing Git checkout root before selecting the module or profile;
the profile defaults to `<repository-root>/releasekit.json`. A successful command prints the
captured module, version, full tag, and commit. It checks the committed release note and
local Git state, runs the standard offline Go test suite (with `-race` when
`validation.go.race` is true), and runs a separate `go vet -mod=readonly ./...`

View File

@@ -1,8 +1,9 @@
# Configuration
ReleaseKit reads a JSON profile from `<repository>/releasekit.json` by default.
The repository directory defaults to the current directory. A relative profile
path is resolved from that repository directory; an absolute path is used as-is.
ReleaseKit reads a JSON profile from `<repository-root>/releasekit.json` by default.
The repository argument defaults to the current directory; the CLI resolves its
containing Git checkout root. A relative profile path is resolved from that root;
an absolute path is used as-is.
The current profile fields are:

View File

@@ -24,7 +24,14 @@ review gates. Add focused completion markers as issues are resolved.
## Issue 1: Resolve the actual Git repository root before release identity (P1)
Status: open. Category: architecture gap and incorrect publication.
Status: complete. Category: architecture gap and incorrect publication.
**Completion:** Source operations and CLI profile selection resolve the containing
Git checkout root before module selection. Regression tests verify that a child
directory cannot bypass a failing root suite or publish its tag, explicit nested
selection binds its own source and tag, aliases and independent worktrees retain
their identity, and dependency preparation works without Git. The focused public
API tests and candidate, delivery, CLI, and profile suites pass offline.
**Evidence:** [candidate capture](../../internal/candidate/check.go) treats
`filepath.Abs(request.RepositoryDir)` as the repository root, and

View File

@@ -8,7 +8,6 @@ import (
"fmt"
"io"
"os/exec"
"path/filepath"
"strings"
"gitea.maximumdirect.net/eric/ReleaseKit/internal/git"
@@ -79,7 +78,7 @@ func Capture(ctx context.Context, request Request) (Candidate, error) {
return captured, errors.New("product is required")
}
repositoryDir, err := filepath.Abs(request.RepositoryDir)
repositoryDir, err := git.RepositoryRoot(ctx, request.RepositoryDir)
if err != nil {
return captured, fmt.Errorf("resolve repository directory: %w", err)
}

View File

@@ -67,7 +67,7 @@ func Deliver(ctx context.Context, request Request) (Result, error) {
if request.Build != nil && request.Prebuilt != nil {
return result, errors.New("delivery cannot use both a managed build and prebuilt artifacts")
}
repositoryDir, err := filepath.Abs(request.RepositoryDir)
repositoryDir, err := git.RepositoryRoot(ctx, request.RepositoryDir)
if err != nil {
return result, fmt.Errorf("resolve repository directory: %w", err)
}

View File

@@ -6,6 +6,7 @@ import (
"fmt"
"io"
"path"
"path/filepath"
"strconv"
"strings"
@@ -30,6 +31,24 @@ type TaggedSource struct {
TagObjectType string
}
// RepositoryRoot resolves the containing checkout, including independent
// worktrees and directories reached through filesystem aliases.
func RepositoryRoot(ctx context.Context, directory string) (string, error) {
if directory == "" {
directory = "."
}
root, err := run(ctx, directory, nil, "rev-parse", "--show-toplevel")
if err != nil {
return "", fmt.Errorf("resolve Git repository root: %w", err)
}
root = strings.TrimSuffix(root, "\n")
resolved, err := filepath.EvalSymlinks(root)
if err != nil {
return "", fmt.Errorf("resolve Git repository directory: %w", err)
}
return resolved, nil
}
// CaptureHead records the checkout commit before identity derived from files
// in that commit is inspected.
func CaptureHead(ctx context.Context, repositoryDir string) (string, error) {

84
repository_test.go Normal file
View File

@@ -0,0 +1,84 @@
package releasekit_test
import (
"context"
"os"
"path/filepath"
"strings"
"testing"
releasekit "gitea.maximumdirect.net/eric/ReleaseKit"
)
func TestSourceOperationsResolveContainingRepository(t *testing.T) {
repo := newPublishFixture(t)
const module = "apps/widget"
addNestedReleaseModule(t, repo.work, module, "example.com/widget", "v1.2.3")
gitRun(t, repo.work, "add", ".")
gitRun(t, repo.work, "commit", "-m", "Add nested module")
gitRun(t, repo.work, "-c", "remote.origin.mirror=false", "push", "origin", "HEAD:refs/heads/main")
child := filepath.Join(repo.work, filepath.FromSlash(module))
t.Setenv("RELEASEKIT_FAIL_FIXTURE_SUITE", "1")
request := releasekit.ReleaseRequest{RepositoryDir: child, Product: "Sample App", Version: "v1.2.3"}
captured, err := releasekit.Check(context.Background(), request)
if err == nil || captured.RepositoryDir != repo.work || captured.ModuleDir != repo.work || captured.ModulePath != "example.com/releasekit-publish-fixture" || captured.Tag != request.Version {
t.Fatalf("default check = %#v, error = %v; want failing root module", captured, err)
}
published, err := releasekit.Publish(context.Background(), request)
if err == nil || published.State != releasekit.PublicationNotAttempted || published.TagObject != "" || published.Tag != request.Version {
t.Fatalf("default publication = %#v, error = %v", published, err)
}
if refs := gitText(t, repo.bare, "tag", "--list", request.Version); refs != "" {
t.Fatalf("failing root tag was published: %s", refs)
}
for _, selected := range []string{"", module} {
wantModule, wantPath, wantTag := repo.work, "example.com/releasekit-publish-fixture", request.Version
if selected != "" {
wantModule, wantPath, wantTag = child, "example.com/widget", module+"/"+request.Version
}
built, err := releasekit.Build(context.Background(), releasekit.BuildRequest{RepositoryDir: child, ModuleDir: selected, Version: request.Version})
if err != nil || built.RepositoryDir != repo.work || built.ModuleDir != wantModule || built.ModulePath != wantPath || built.Tag != wantTag {
t.Fatalf("build %q = %#v, error = %v", selected, built, err)
}
delivered, err := releasekit.Deliver(context.Background(), releasekit.DeliveryRequest{RepositoryDir: child, ModuleDir: selected, Product: request.Product, Version: request.Version, ExpectedTag: "mismatch"})
if err == nil || !strings.Contains(err.Error(), "expected delivery tag") || delivered.RepositoryDir != repo.work || delivered.ModuleDir != wantModule || delivered.ModulePath != wantPath || delivered.Tag != wantTag {
t.Fatalf("delivery %q = %#v, error = %v", selected, delivered, err)
}
}
request.ModuleDir = module
captured, err = releasekit.Check(context.Background(), request)
if err != nil || captured.ModuleDir != child || captured.ModulePath != "example.com/widget" || captured.Tag != module+"/"+request.Version {
t.Fatalf("nested check = %#v, error = %v", captured, err)
}
published, err = releasekit.Publish(context.Background(), request)
if err != nil || published.State != releasekit.PublicationPublished || published.Tag != captured.Tag {
t.Fatalf("nested publication = %#v, error = %v", published, err)
}
if got := gitText(t, repo.bare, "rev-parse", "refs/tags/"+captured.Tag+"^{commit}"); got != captured.Commit {
t.Fatalf("published commit = %s; want %s", got, captured.Commit)
}
}
func TestRepositoryResolutionSupportsAliasesAndIndependentWorktrees(t *testing.T) {
repo := newPublishFixture(t)
alias := filepath.Join(t.TempDir(), "alias")
if err := os.Symlink(repo.work, alias); err != nil {
t.Fatal(err)
}
worktree := filepath.Join(t.TempDir(), "worktree")
gitRun(t, repo.work, "worktree", "add", "--detach", worktree, "HEAD")
for _, tt := range []struct{ directory, wantRoot string }{{alias, repo.work}, {filepath.Join(worktree, "docs"), worktree}} {
result, err := releasekit.Build(context.Background(), releasekit.BuildRequest{RepositoryDir: tt.directory, Version: "v1.2.3"})
if err != nil || result.RepositoryDir != tt.wantRoot || result.ModuleDir != tt.wantRoot || result.CandidateCommit != repo.commit {
t.Fatalf("build through %q = %#v, error = %v", tt.directory, result, err)
}
}
}
func TestDependencyPreparationDoesNotRequireGitCheckout(t *testing.T) {
directory := t.TempDir()
writeFile(t, directory, "go.mod", "module example.com/preparation\n\ngo 1.26.0\n")
if err := releasekit.PrepareBuildDependencies(context.Background(), releasekit.DependencyPreparationRequest{RepositoryDir: directory}); err != nil {
t.Fatalf("prepare module outside Git checkout: %v", err)
}
}