From 7555d3e6c9517a3ace50dda1929567377c111e42 Mon Sep 17 00:00:00 2001 From: Eric Rakestraw Date: Sat, 10 Oct 2026 14:03:57 +0000 Subject: [PATCH] Resolve source operations from the Git checkout root --- build.go | 9 +++- check.go | 6 ++- cmd/releasekit/main.go | 8 +++ cmd/releasekit/repository_test.go | 25 +++++++++ docs/cli.md | 5 +- docs/config.md | 7 +-- docs/roadmap/remediation.md | 9 +++- internal/candidate/check.go | 3 +- internal/delivery/delivery.go | 2 +- internal/git/source.go | 19 +++++++ repository_test.go | 84 +++++++++++++++++++++++++++++++ 11 files changed, 164 insertions(+), 13 deletions(-) create mode 100644 cmd/releasekit/repository_test.go create mode 100644 repository_test.go diff --git a/build.go b/build.go index 84f5a6e..7b72504 100644 --- a/build.go +++ b/build.go @@ -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 } diff --git a/check.go b/check.go index 9a69d4a..654a4f7 100644 --- a/check.go +++ b/check.go @@ -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 diff --git a/cmd/releasekit/main.go b/cmd/releasekit/main.go index e55021f..ecbcc1a 100644 --- a/cmd/releasekit/main.go +++ b/cmd/releasekit/main.go @@ -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: diff --git a/cmd/releasekit/repository_test.go b/cmd/releasekit/repository_test.go new file mode 100644 index 0000000..5a5fd14 --- /dev/null +++ b/cmd/releasekit/repository_test.go @@ -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) + } +} diff --git a/docs/cli.md b/docs/cli.md index d06783f..c214ad6 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -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 `/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 `/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 ./...` diff --git a/docs/config.md b/docs/config.md index a365ef2..3e163ae 100644 --- a/docs/config.md +++ b/docs/config.md @@ -1,8 +1,9 @@ # Configuration -ReleaseKit reads a JSON profile from `/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 `/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: diff --git a/docs/roadmap/remediation.md b/docs/roadmap/remediation.md index b80f13d..fff8ff5 100644 --- a/docs/roadmap/remediation.md +++ b/docs/roadmap/remediation.md @@ -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 diff --git a/internal/candidate/check.go b/internal/candidate/check.go index 175636d..ccd27ff 100644 --- a/internal/candidate/check.go +++ b/internal/candidate/check.go @@ -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) } diff --git a/internal/delivery/delivery.go b/internal/delivery/delivery.go index 30fd67f..7333f60 100644 --- a/internal/delivery/delivery.go +++ b/internal/delivery/delivery.go @@ -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) } diff --git a/internal/git/source.go b/internal/git/source.go index 65729bd..394001c 100644 --- a/internal/git/source.go +++ b/internal/git/source.go @@ -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) { diff --git a/repository_test.go b/repository_test.go new file mode 100644 index 0000000..a944681 --- /dev/null +++ b/repository_test.go @@ -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) + } +}