diff --git a/internal/changelog/github.go b/internal/changelog/github.go index 286d0fdb..4bfe0ee4 100644 --- a/internal/changelog/github.go +++ b/internal/changelog/github.go @@ -158,29 +158,3 @@ func (r *graphQLResponse) UnmarshalJSON(data []byte) error { return nil } - -// LookupConventionalCommitUsernames looks up GitHub usernames for conventional commits -func LookupConventionalCommitUsernames(commits []ConventionalCommit, repo string) []ConventionalCommit { - if repo == "" || len(commits) == 0 { - return commits - } - - // Convert to git.Commit for batch lookup - gitCommits := make([]git.Commit, len(commits)) - for i, c := range commits { - gitCommits[i] = git.Commit{ - Hash: c.FullHash, - AuthorEmail: c.AuthorEmail, - } - } - - // Perform batch lookup - gitCommits = LookupGitHubUsernames(gitCommits, repo) - - // Copy usernames back - for i := range commits { - commits[i].GitHubUsername = gitCommits[i].GitHubUsername - } - - return commits -} diff --git a/internal/changelog/github_coverage_test.go b/internal/changelog/github_coverage_test.go index 99127403..d5444b61 100644 --- a/internal/changelog/github_coverage_test.go +++ b/internal/changelog/github_coverage_test.go @@ -60,20 +60,6 @@ func TestUnmarshalJSON_TopLevelError(t *testing.T) { assert.Error(t, err) } -func TestLookupConventionalCommitUsernames_WithCommits(t *testing.T) { - // Verify the conversion loop and the username copy-back loop both execute. - // The gh CLI call will fail in the test environment; the important thing is - // that the function does not panic and returns the same number of commits. - commits := []ConventionalCommit{ - {FullHash: "abc1234567", AuthorEmail: "alice@example.com", Description: "first change"}, - {FullHash: "def1234567", AuthorEmail: "bob@example.com", Description: "second change"}, - } - result := LookupConventionalCommitUsernames(commits, "owner/repo") - require.Len(t, result, 2) - assert.Equal(t, "first change", result[0].Description) - assert.Equal(t, "second change", result[1].Description) -} - func TestLookupGitHubUsernames_InvalidRepoFormat(t *testing.T) { // A repo without a slash propagates to lookupAuthorsBatch which returns empty, // so no commit gets a username. diff --git a/internal/changelog/github_test.go b/internal/changelog/github_test.go index 2eb481b4..b6fceb9b 100644 --- a/internal/changelog/github_test.go +++ b/internal/changelog/github_test.go @@ -157,41 +157,6 @@ func TestGraphQLResponseUnmarshal_EmptyRepository(t *testing.T) { } } -func TestLookupConventionalCommitUsernames_EmptyInputs(t *testing.T) { - tests := []struct { - name string - commits []ConventionalCommit - repo string - }{ - { - name: "empty commits", - commits: []ConventionalCommit{}, - repo: "owner/repo", - }, - { - name: "nil commits", - commits: nil, - repo: "owner/repo", - }, - { - name: "empty repo", - commits: []ConventionalCommit{ - {FullHash: "abc123", AuthorEmail: "test@example.com"}, - }, - repo: "", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result := LookupConventionalCommitUsernames(tt.commits, tt.repo) - if len(result) != len(tt.commits) { - t.Errorf("LookupConventionalCommitUsernames() changed slice length: got %d, want %d", len(result), len(tt.commits)) - } - }) - } -} - func TestLookupGitHubUsernames_NoEmails(t *testing.T) { // Commits without author emails should not trigger API calls commits := []git.Commit{ diff --git a/internal/changes/detect.go b/internal/changes/detect.go index 482d205e..056bdf04 100644 --- a/internal/changes/detect.go +++ b/internal/changes/detect.go @@ -51,11 +51,3 @@ func Detect(cfg *config.TrunkConfig, baseSHA, headSHA string) (*DetectResult, er func isTriggered(triggers []string, changedFiles []string) bool { return config.MatchAnyTrigger(triggers, changedFiles) } - -// matchGlob reports whether a single glob pattern matches a path. Negation is -// handled at the pattern-list level by config.MatchTrigger; this helper matches -// the bare glob and is retained for the existing unit coverage. A leading "!" -// is stripped before matching. -func matchGlob(pattern, path string) bool { - return config.MatchGlobPattern(pattern, path) -} diff --git a/internal/changes/detect_test.go b/internal/changes/detect_test.go index 2804045a..8d6dfaa7 100644 --- a/internal/changes/detect_test.go +++ b/internal/changes/detect_test.go @@ -4,51 +4,6 @@ import ( "testing" ) -func TestMatchGlob(t *testing.T) { - tests := []struct { - pattern string - path string - want bool - }{ - // Simple patterns - {"*.go", "main.go", true}, - {"*.go", "main.txt", false}, - {"src/*.go", "src/main.go", true}, - {"src/*.go", "src/sub/main.go", false}, - - // Double star patterns - {"src/**", "src/main.go", true}, - {"src/**", "src/sub/main.go", true}, - {"src/**/*.go", "src/main.go", true}, - {"src/**/*.go", "src/sub/main.go", true}, - {"src/**/*.go", "src/sub/deep/main.go", true}, - {"**/*.go", "main.go", true}, - {"**/*.go", "src/main.go", true}, - {"**", "anything/at/all.txt", true}, - - // Question mark: GitHub Actions grammar, zero or one of the - // preceding character. A leading "?" has nothing preceding it and is - // treated as a literal character. - {"*.jsx?", "page.js", true}, - {"*.jsx?", "page.jsx", true}, - {"*.jsx?", "page.jsxx", false}, - {"?.go", "a.go", false}, - - // No match - {"src/**", "other/main.go", false}, - {"*.yaml", "config.json", false}, - } - - for _, tt := range tests { - t.Run(tt.pattern+"_"+tt.path, func(t *testing.T) { - got := matchGlob(tt.pattern, tt.path) - if got != tt.want { - t.Errorf("matchGlob(%q, %q) = %v, want %v", tt.pattern, tt.path, got, tt.want) - } - }) - } -} - func TestIsTriggered(t *testing.T) { tests := []struct { name string diff --git a/internal/generate/env_gates_test.go b/internal/generate/env_gates_test.go index b8f7d2ff..e5f67f82 100644 --- a/internal/generate/env_gates_test.go +++ b/internal/generate/env_gates_test.go @@ -299,20 +299,6 @@ func TestEnvGates_Promote_ProdDeployJob_OnlyFinalEnvGated(t *testing.T) { assert.Contains(t, singleBlock, "environment:", "deploy-svc single job must carry job-level environment: since staging has gha_environment") } -// TestEnvGates_Helper_envGHAName verifies the envGHAName helper returns the -// gha_environment value when configured and falls back to the cascade env name. -func TestEnvGates_Helper_envGHAName(t *testing.T) { - cfg := &config.TrunkConfig{ - Environments: []config.EnvironmentEntry{ - {Name: "prod", EnvironmentConfig: config.EnvironmentConfig{GHAEnvironment: "production"}}, - }, - } - - assert.Equal(t, "production", envGHAName(cfg, "prod"), "should return gha_environment when configured") - assert.Equal(t, "dev", envGHAName(cfg, "dev"), "should fall back to cascade env name when not configured") - assert.Equal(t, "staging", envGHAName(cfg, "staging"), "should fall back to cascade env name for unconfigured env") -} - // TestEnvGates_Helper_anyEnvHasGHAConfig verifies anyEnvHasGHAConfig reports // correctly for configs with and without gha_environment. func TestEnvGates_Helper_anyEnvHasGHAConfig(t *testing.T) { diff --git a/internal/generate/generator.go b/internal/generate/generator.go index 706fce7b..7424325d 100644 --- a/internal/generate/generator.go +++ b/internal/generate/generator.go @@ -83,17 +83,6 @@ func workflowDispatchTarget(path string) string { return filepath.Base(path) } -// envGHAName returns the GitHub Environment name for a given cascade environment -// name. When the config has an EnvironmentConfig entry for that env whose -// GHAEnvironment field is non-empty, that value is returned; otherwise the -// cascade env name itself is used as the GitHub Environment name. -func envGHAName(cfg *config.TrunkConfig, cascadeEnvName string) string { - if ec, ok := cfg.EnvConfig(cascadeEnvName); ok && ec.GHAEnvironment != "" { - return ec.GHAEnvironment - } - return cascadeEnvName -} - // anyEnvHasGHAConfig reports whether any environment in the config has an // EnvironmentConfig entry with a non-empty GHAEnvironment field. func anyEnvHasGHAConfig(cfg *config.TrunkConfig) bool { diff --git a/internal/generate/graph.go b/internal/generate/graph.go index 845ae3c5..f7a95dd3 100644 --- a/internal/generate/graph.go +++ b/internal/generate/graph.go @@ -235,26 +235,6 @@ func (g *DependencyGraph) TopologicalSort() ([]string, error) { return result, nil } -// GetAllDependencies returns all transitive dependencies for a node -func (g *DependencyGraph) GetAllDependencies(node string) []string { - visited := make(map[string]bool) - var deps []string - - var collect func(n string) - collect = func(n string) { - for _, dep := range g.Edges[n] { - if !visited[dep] { - visited[dep] = true - deps = append(deps, dep) - collect(dep) - } - } - } - - collect(node) - return deps -} - // GetDirectDependencies returns only direct (hard) dependencies for a node func (g *DependencyGraph) GetDirectDependencies(node string) []string { return g.Edges[node] diff --git a/internal/generate/graph_test.go b/internal/generate/graph_test.go index 83c3bce7..313569f1 100644 --- a/internal/generate/graph_test.go +++ b/internal/generate/graph_test.go @@ -60,28 +60,6 @@ func TestTopologicalSort(t *testing.T) { assert.Less(t, apiIdx, servicesIdx) } -func TestGetAllDependencies(t *testing.T) { - cfg := &config.TrunkConfig{ - Builds: []config.BuildConfig{ - {Name: "base", DependsOn: []string{}}, - {Name: "app", DependsOn: []string{"base"}}, - {Name: "worker", DependsOn: []string{"app"}}, - }, - } - - graph := BuildDependencyGraph(cfg) - - // worker depends on app and base (transitively) - using prefixed job IDs - deps := graph.GetAllDependencies("build-worker") - assert.Contains(t, deps, "build-app") - assert.Contains(t, deps, "build-base") - - // app depends only on base - deps = graph.GetAllDependencies("build-app") - assert.Contains(t, deps, "build-base") - assert.NotContains(t, deps, "build-worker") -} - func TestGetDirectDependencies(t *testing.T) { cfg := &config.TrunkConfig{ Builds: []config.BuildConfig{ diff --git a/internal/git/boundary_test.go b/internal/git/boundary_test.go index 07a9d330..5706b8a8 100644 --- a/internal/git/boundary_test.go +++ b/internal/git/boundary_test.go @@ -4,7 +4,6 @@ import ( "os" "path/filepath" "testing" - "time" "github.com/stablekernel/cascade/internal/taggrammar" ) @@ -57,25 +56,6 @@ func TestGetLatestReleaseTagSpec_NonRepoDirDoesNotReadEnclosingRepo(t *testing.T } } -// TestCommitAndPushWithRetry_NonRepoDirDoesNotCommitToEnclosingRepo pins the -// boundary on the write path: a WithDir target that is not itself a repository -// must fail before staging anything, never record a commit in the enclosing -// repository. -func TestCommitAndPushWithRetry_NonRepoDirDoesNotCommitToEnclosingRepo(t *testing.T) { - outer, inner := newEnclosedNonRepoDir(t) - writeFileAt(t, inner, "state.yaml", "state: v1\n") - - err := CommitAndPushWithRetry("state.yaml", "chore: escaped state write", - WithDir(inner), WithBackoff(time.Millisecond)) - if err == nil { - t.Fatal("CommitAndPushWithRetry in a non-repository dir = nil, want an error") - } - - if got := gitOutAt(t, outer, "rev-list", "--count", "HEAD"); got != "1" { - t.Errorf("enclosing repository commit count = %s, want 1: the escaped write landed on the wrong repo", got) - } -} - // TestRefetchAndReset_NonRepoDirErrors pins the boundary on the reset path: a // non-repository dir must error, not fetch and hard-reset the enclosing // repository's working tree (which would destroy unrelated local state). diff --git a/internal/git/git.go b/internal/git/git.go index b1f8d5f6..f2d1d815 100644 --- a/internal/git/git.go +++ b/internal/git/git.go @@ -41,15 +41,6 @@ func BoundaryEnv(dir string) []string { return append(os.Environ(), "GIT_CEILING_DIRECTORIES="+filepath.Dir(abs)) } -// IsValidVersionTag reports whether tag is a well-formed cascade version tag -// under the default grammar. Tags that do not match (for example a -// vX.Y.Z-dryrun.N exercise tag, a foreign "nightly" or "latest" tag, or a typo) -// are invisible to version discovery so they can never be mistaken for the -// latest released or prereleased version. -func IsValidVersionTag(tag string) bool { - return IsValidVersionTagSpec(taggrammar.Default(), tag) -} - // IsValidVersionTagSpec reports whether tag is a well-formed version tag under // spec. Version discovery and git both read this from the canonical grammar, so // the predicate can never drift from the parser the way a hand-copied regex @@ -407,59 +398,25 @@ func newPushOptions(opts []Option) pushOptions { return o } -// CommitAndPushWithRetry stages filePath, commits it with message, and pushes -// to the current branch's upstream, retrying a rejected push up to -// pushRetryAttempts times behind a pull --rebase. A "nothing to commit" state is -// treated as success (no-op). This -// is the manifest state-write path shared by promote and hotfix finalize: an -// API-created commit on real GitHub goes through a different path, so this is the -// plain-git fallback used when committing locally. -// -// Optional behaviour is supplied through Options: WithDir runs the commands in a -// specific repository, and WithBackoff tunes the retry delay. With no options the -// call behaves identically to the original positional signature. -func CommitAndPushWithRetry(filePath, message string, opts ...Option) error { - o := newPushOptions(opts) - - cmd := exec.Command("git", "add", filePath) - cmd.Dir = o.dir - cmd.Env = BoundaryEnv(o.dir) - if out, err := cmd.CombinedOutput(); err != nil { - return fmt.Errorf("git add failed: %s: %w", string(out), err) - } - - cmd = exec.Command("git", "commit", "-m", message) - cmd.Dir = o.dir - cmd.Env = BoundaryEnv(o.dir) - if out, err := cmd.CombinedOutput(); err != nil { - if strings.Contains(string(out), "nothing to commit") { - return nil - } - return fmt.Errorf("git commit failed: %s: %w", string(out), err) - } - - return pushWithRebaseRetry(o) -} - // PushWithRebaseRetry pushes the current branch to its upstream, retrying a // rejected push up to pushRetryAttempts times (for example a non-fast-forward // caused by a concurrent state writer landing on trunk between checkout and -// push). It is the push half of CommitAndPushWithRetry, exposed for callers that -// stage and commit through their own flow and only need the shared retry -// behaviour. By default a rejected push is retried behind a "git pull --rebase", -// aborting the rebase and returning the wrapped error on a conflict rather than -// leaving the repository mid-rebase. Passing WithReapply switches the retry to -// re-derive the owned state leaf against re-fetched trunk instead, so an owned -// leaf converges against a concurrent sibling's adjacent leaf without conflict. +// push). It is exposed for callers that stage and commit through their own flow +// and only need the shared retry behaviour. By default a rejected push is +// retried behind a "git pull --rebase", aborting the rebase and returning the +// wrapped error on a conflict rather than leaving the repository mid-rebase. +// Passing WithReapply switches the retry to re-derive the owned state leaf +// against re-fetched trunk instead, so an owned leaf converges against a +// concurrent sibling's adjacent leaf without conflict. func PushWithRebaseRetry(opts ...Option) error { return pushWithRebaseRetry(newPushOptions(opts)) } -// pushWithRebaseRetry is the single rebase-retry loop shared by -// CommitAndPushWithRetry and PushWithRebaseRetry. Keeping one implementation -// means the re-apply and rebase-abort-on-conflict behaviours live in exactly one -// place. When o.reapply is set it re-derives the owned leaf against re-fetched -// trunk on each rejected push; otherwise it falls back to a textual rebase. +// pushWithRebaseRetry is the single rebase-retry loop behind PushWithRebaseRetry. +// Keeping one implementation means the re-apply and rebase-abort-on-conflict +// behaviours live in exactly one place. When o.reapply is set it re-derives the +// owned leaf against re-fetched trunk on each rejected push; otherwise it falls +// back to a textual rebase. // // Every attempt emits a stable "cascade-state-write:" log line carrying the // attempt count, so a live run can be grepped to prove a concurrent wave @@ -648,34 +605,6 @@ func CurrentBranch() (string, error) { return branch, nil } -// CommitAndPush stages a file, commits with the given message, and pushes to origin. -func CommitAndPush(filePath, message string) error { - // Dry-run backstop: refuse the commit-and-push under --dry-run. - if err := globals.GuardMutation(fmt.Sprintf("commit and push %s", filePath)); err != nil { - return err - } - - // Stage the file - cmd := exec.Command("git", "add", filePath) - if output, err := cmd.CombinedOutput(); err != nil { - return fmt.Errorf("git add: %w\n%s", err, output) - } - - // Commit - cmd = exec.Command("git", "commit", "-m", message) - if output, err := cmd.CombinedOutput(); err != nil { - return fmt.Errorf("git commit: %w\n%s", err, output) - } - - // Push - cmd = exec.Command("git", "push") - if output, err := cmd.CombinedOutput(); err != nil { - return fmt.Errorf("git push: %w\n%s", err, output) - } - - return nil -} - // IsAncestor reports whether ancestor is an ancestor of descendant by running // "git merge-base --is-ancestor". An exit code of 0 means true, an exit code of 1 // means false, and any other exit code or execution failure is returned as an error. @@ -723,22 +652,6 @@ func BranchExists(remote, name string) (bool, error) { return false, fmt.Errorf("git rev-parse --verify %s: %w", ref, err) } -// RemoteBranchSHA returns the SHA of the remote-tracking ref -// refs/remotes// by running "git rev-parse". The returned SHA is -// whitespace-trimmed. An error is returned if the ref cannot be resolved. -// -// This resolves a remote-tracking ref, so the remote must have been fetched first. -// A shallow or partial fetch that omits the branch will cause this to fail. -func RemoteBranchSHA(remote, name string) (string, error) { - ref := fmt.Sprintf("refs/remotes/%s/%s", remote, name) - cmd := exec.Command("git", "rev-parse", ref) - output, err := cmd.Output() - if err != nil { - return "", fmt.Errorf("git rev-parse %s: %w", ref, err) - } - return strings.TrimSpace(string(output)), nil -} - // ListRemoteBranches returns the branch names known for the given remote via the // remote-tracking refs refs/remotes//*. The remote prefix and the symbolic // HEAD pointer are stripped, so "refs/remotes/origin/env/test" is returned as diff --git a/internal/git/git_test.go b/internal/git/git_test.go index 82e08402..e5c6f09b 100644 --- a/internal/git/git_test.go +++ b/internal/git/git_test.go @@ -296,60 +296,6 @@ func TestBranchExists(t *testing.T) { } } -func TestRemoteBranchSHA(t *testing.T) { - dir := newScratchRepo(t) - - wantSHA := commitFile(t, "a.txt", "one", "first commit") - runGit(t, "branch", "-M", "main") - runGit(t, "branch", "feature") - - clone := t.TempDir() - runGit(t, "clone", dir, clone) - - orig, err := os.Getwd() - if err != nil { - t.Fatalf("getwd: %v", err) - } - if err := os.Chdir(clone); err != nil { - t.Fatalf("chdir clone: %v", err) - } - t.Cleanup(func() { - if err := os.Chdir(orig); err != nil { - t.Fatalf("restore cwd: %v", err) - } - }) - runGit(t, "fetch", "origin") - - tests := []struct { - name string - remote string - branch string - want string - wantErr bool - }{ - {name: "existing branch", remote: "origin", branch: "feature", want: wantSHA}, - {name: "missing branch", remote: "origin", branch: "nope", wantErr: true}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got, err := RemoteBranchSHA(tt.remote, tt.branch) - if tt.wantErr { - if err == nil { - t.Fatalf("RemoteBranchSHA() expected error, got nil") - } - return - } - if err != nil { - t.Fatalf("RemoteBranchSHA() unexpected error: %v", err) - } - if got != tt.want { - t.Errorf("RemoteBranchSHA() = %q, want %q", got, tt.want) - } - }) - } -} - // gitAt runs a git command in dir via "git -C" and fails the test on error. An // empty dir runs git without -C (in the process working directory). func gitAt(t *testing.T, dir string, args ...string) { @@ -372,75 +318,6 @@ func configRepo(t *testing.T, dir string) { gitAt(t, dir, "config", "commit.gpgsign", "false") } -// TestCommitAndPushWithRetry_AbortsRebaseOnConflict proves that when the retry -// loop's "git pull --rebase" hits a conflict, the helper aborts the rebase and -// returns the error instead of looping into a guaranteed-failing push and -// leaving the repository in a conflicted mid-rebase state. -func TestCommitAndPushWithRetry_AbortsRebaseOnConflict(t *testing.T) { - origin := t.TempDir() - if out, err := exec.Command("git", "init", "--bare", "--initial-branch=main", origin).CombinedOutput(); err != nil { - t.Fatalf("git init --bare: %v\n%s", err, out) - } - - // Seed origin/main with a base version of the shared state file. - seed := t.TempDir() - gitAt(t, "", "clone", origin, seed) - configRepo(t, seed) - if err := os.WriteFile(filepath.Join(seed, "state.txt"), []byte("base\n"), 0o600); err != nil { - t.Fatalf("write seed file: %v", err) - } - gitAt(t, seed, "add", "state.txt") - gitAt(t, seed, "commit", "-m", "seed state") - gitAt(t, seed, "push", "origin", "main") - - // A local clone at the base state; this is where the helper runs. - local := t.TempDir() - gitAt(t, "", "clone", origin, local) - configRepo(t, local) - - // Another writer advances origin/main with a conflicting change to the - // same file, so the local push will be rejected and the rebase will - // conflict. - other := t.TempDir() - gitAt(t, "", "clone", origin, other) - configRepo(t, other) - if err := os.WriteFile(filepath.Join(other, "state.txt"), []byte("remote change\n"), 0o600); err != nil { - t.Fatalf("write other file: %v", err) - } - gitAt(t, other, "add", "state.txt") - gitAt(t, other, "commit", "-m", "remote change") - gitAt(t, other, "push", "origin", "main") - - // Run the helper from the local clone. - orig, err := os.Getwd() - if err != nil { - t.Fatalf("getwd: %v", err) - } - if err := os.Chdir(local); err != nil { - t.Fatalf("chdir local: %v", err) - } - t.Cleanup(func() { - if err := os.Chdir(orig); err != nil { - t.Fatalf("restore cwd: %v", err) - } - }) - if err := os.WriteFile(filepath.Join(local, "state.txt"), []byte("local change\n"), 0o600); err != nil { - t.Fatalf("write local file: %v", err) - } - - err = CommitAndPushWithRetry("state.txt", "local change") - if err == nil { - t.Fatal("CommitAndPushWithRetry() with a conflicting remote: expected error, got nil") - } - - // The repository must not be left mid-rebase. - for _, name := range []string{"rebase-merge", "rebase-apply"} { - if _, statErr := os.Stat(filepath.Join(local, ".git", name)); statErr == nil { - t.Fatalf("repository left mid-rebase: .git/%s still present", name) - } - } -} - // sharedRemoteClones builds a bare remote plus two working clones tracking it. // The seed clone commits and pushes seedFile; the other clone then advances the // remote with otherFile so a subsequent push from seed is rejected as @@ -680,34 +557,6 @@ func TestGetLatestReleaseTag_ScopesToDir(t *testing.T) { } } -func TestIsValidVersionTag(t *testing.T) { - tests := []struct { - tag string - want bool - }{ - {"v1.2.3", true}, - {"v0.5.1", true}, - {"v1.0.1-rc.0", true}, - {"v1.0.1-rc.4.hotfix.5", true}, - {"1.2.3", true}, // empty prefix is allowed - {"release1.2.3", true}, // alphabetic prefix is allowed - {"v0.6.0-dryrun.1", false}, - {"vnightly", false}, - {"nightly", false}, - {"latest", false}, - {"v1.2", false}, - {"v1.2.3-rc", false}, - {"", false}, - } - for _, tt := range tests { - t.Run(tt.tag, func(t *testing.T) { - if got := IsValidVersionTag(tt.tag); got != tt.want { - t.Errorf("IsValidVersionTag(%q) = %v, want %v", tt.tag, got, tt.want) - } - }) - } -} - // TestGetLatestReleaseTag_CustomTokenNotMistakenForRelease proves the release // classifier narrows on "parses with no pre-release" rather than a hard-wired // "-rc." substring. Under a beta grammar, v1.2.3-beta.1 is a pre-release and diff --git a/internal/git/retry_test.go b/internal/git/retry_test.go deleted file mode 100644 index 0eb36dab..00000000 --- a/internal/git/retry_test.go +++ /dev/null @@ -1,143 +0,0 @@ -package git - -import ( - "os" - "os/exec" - "path/filepath" - "strings" - "testing" -) - -// cloneRepo clones src into a fresh temp dir, switches the working directory to -// the clone for the duration of the test, configures a committer identity, and -// returns the clone path. The original working directory is restored on cleanup. -func cloneRepo(t *testing.T, src string) string { - t.Helper() - - clone := t.TempDir() - cmd := exec.Command("git", "clone", src, clone) - if out, err := cmd.CombinedOutput(); err != nil { - t.Fatalf("git clone: %v\n%s", err, out) - } - - orig, err := os.Getwd() - if err != nil { - t.Fatalf("getwd: %v", err) - } - if err := os.Chdir(clone); err != nil { - t.Fatalf("chdir clone: %v", err) - } - t.Cleanup(func() { - if err := os.Chdir(orig); err != nil { - t.Fatalf("restore cwd: %v", err) - } - }) - - runGit(t, "config", "user.email", "clone@example.com") - runGit(t, "config", "user.name", "Clone User") - runGit(t, "config", "commit.gpgsign", "false") - runGit(t, "config", "pull.rebase", "true") - - return clone -} - -// chdir switches the working directory to dir for the duration of the test and -// restores the original on cleanup. -func chdir(t *testing.T, dir string) { - t.Helper() - orig, err := os.Getwd() - if err != nil { - t.Fatalf("getwd: %v", err) - } - if err := os.Chdir(dir); err != nil { - t.Fatalf("chdir: %v", err) - } - t.Cleanup(func() { - if err := os.Chdir(orig); err != nil { - t.Fatalf("restore cwd: %v", err) - } - }) -} - -// readHeadFile returns the contents of name at the current HEAD of the repo in dir. -func readHeadFile(t *testing.T, dir, name string) string { - t.Helper() - out, err := exec.Command("git", "-C", dir, "show", "HEAD:"+name).Output() - if err != nil { - t.Fatalf("git show HEAD:%s: %v", name, err) - } - return string(out) -} - -// TestCommitAndPushWithRetry_RecoversFromNonFastForward proves the resilience the -// external-update path relies on: when the remote has advanced behind the local -// checkout's back (a stale parent, exactly the concurrent external-update race), -// a plain push is rejected non-fast-forward, but CommitAndPushWithRetry rebases -// and succeeds without dropping either side's change. -func TestCommitAndPushWithRetry_RecoversFromNonFastForward(t *testing.T) { - // Bare origin shared by two working clones. - origin := t.TempDir() - if out, err := exec.Command("git", "init", "--bare", "-b", "main", origin).CombinedOutput(); err != nil { - t.Fatalf("git init --bare: %v\n%s", err, out) - } - - // Seed the origin with a manifest that already carries one external slot. - // Both racers add a NEW, non-adjacent slot, mirroring two upstream artifacts - // updating the same primary manifest concurrently. - const seed = "state:\n dev:\n sha: base\n external:\n existing:\n repo: org/existing\n" - bootstrap := cloneRepo(t, origin) - commitFile(t, "manifest.yaml", seed, "seed manifest") - runGit(t, "push", "origin", "HEAD:main") - - // "racer A" clones the seeded origin. Its checkout becomes stale once B pushes. - racerA := cloneRepo(t, origin) - - // "racer B" clones, prepends its slot, and pushes first. This advances origin - // so racerA's parent is no longer the tip. - const bManifest = "state:\n dev:\n sha: base\n external:\n cdk:\n repo: org/cdk\n existing:\n repo: org/existing\n" - racerB := cloneRepo(t, origin) - if err := os.WriteFile(filepath.Join(racerB, "manifest.yaml"), []byte(bManifest), 0o600); err != nil { - t.Fatalf("write B manifest: %v", err) - } - chdir(t, racerB) - runGit(t, "add", "manifest.yaml") - runGit(t, "commit", "-m", "B: add cdk slot") - runGit(t, "push", "origin", "HEAD:main") - - // racerA now appends its own slot against the stale parent and pushes through - // the retrying path. A plain push here is rejected non-fast-forward. - chdir(t, racerA) - const aManifest = "state:\n dev:\n sha: base\n external:\n existing:\n repo: org/existing\n lambda:\n repo: org/lambda\n" - if err := os.WriteFile(filepath.Join(racerA, "manifest.yaml"), []byte(aManifest), 0o600); err != nil { - t.Fatalf("write A manifest: %v", err) - } - - if err := CommitAndPushWithRetry("manifest.yaml", "A: add lambda slot"); err != nil { - t.Fatalf("CommitAndPushWithRetry() error = %v, want nil (must rebase over B and push)", err) - } - - // Origin tip must contain BOTH commits' subjects: no slot was lost. - logOut, err := exec.Command("git", "-C", origin, "log", "--format=%s", "main").Output() - if err != nil { - t.Fatalf("git log origin: %v", err) - } - log := string(logOut) - if !strings.Contains(log, "A: add lambda slot") { - t.Errorf("origin missing racerA commit; log:\n%s", log) - } - if !strings.Contains(log, "B: add cdk slot") { - t.Errorf("origin missing racerB commit (state loss); log:\n%s", log) - } - - // The manifest at origin HEAD must carry BOTH new slots: the rebase merges - // A's append on top of B's prepend, so no artifact's slot is lost. - got := readHeadFile(t, origin, "manifest.yaml") - if !strings.Contains(got, "lambda") { - t.Errorf("origin HEAD manifest missing racerA (lambda) slot; got:\n%s", got) - } - if !strings.Contains(got, "cdk") { - t.Errorf("origin HEAD manifest missing racerB (cdk) slot - state loss; got:\n%s", got) - } - - _ = bootstrap -} diff --git a/internal/git/version_tag_sync_test.go b/internal/git/version_tag_sync_test.go deleted file mode 100644 index e64847e5..00000000 --- a/internal/git/version_tag_sync_test.go +++ /dev/null @@ -1,49 +0,0 @@ -package git - -import ( - "testing" - - "github.com/stablekernel/cascade/internal/taggrammar" -) - -// TestIsValidVersionTag_MatchesDefaultGrammar pins the git predicate to the -// canonical grammar. Both the git package and version discovery now read tag -// shape from internal/taggrammar, so the old cross-package drift class is gone; -// this test keeps a representative corpus honest against the default spec. -func TestIsValidVersionTag_MatchesDefaultGrammar(t *testing.T) { - spec := taggrammar.Default() - corpus := []string{ - "v1.2.3", - "v0.5.1", - "v0.0.0", - "v10.20.30", - "v1.0.1-rc.0", - "v1.0.1-rc.42", - "v1.0.1-rc.4.hotfix.5", - "1.2.3", - "release1.2.3", - "v0.6.0-dryrun.1", - "v0.6.0-dryrun.10", - "vnightly", - "nightly", - "latest", - "v1.2", - "v1.2.3.4", - "v1.2.3-rc", - "v1.2.3-rc.x", - "v1.2.3-hotfix.1", - "v-1.2.3", - "", - "vlatest", - } - - for _, tag := range corpus { - tag := tag - t.Run(tag, func(t *testing.T) { - want := spec.IsVersionTag(tag) - if got := IsValidVersionTag(tag); got != want { - t.Errorf("IsValidVersionTag(%q) = %v, want %v (predicate drifted from the canonical grammar)", tag, got, want) - } - }) - } -} diff --git a/internal/git/workingtree_test.go b/internal/git/workingtree_test.go index 6666b069..a314415f 100644 --- a/internal/git/workingtree_test.go +++ b/internal/git/workingtree_test.go @@ -174,31 +174,6 @@ func TestCurrentBranch_DetachedHeadErrors(t *testing.T) { } } -func TestCommitAndPush_LandsOnRemote(t *testing.T) { - bare, clone := newCloneWithRemote(t) - - writeFileAt(t, clone, "state.yaml", "state: v1\n") - if err := CommitAndPush("state.yaml", "chore: write state"); err != nil { - t.Fatalf("CommitAndPush: %v", err) - } - - if got := gitOutAt(t, bare, "log", "-1", "--format=%s", "main"); got != "chore: write state" { - t.Errorf("remote tip subject = %q, want the pushed commit", got) - } - if got := gitOutAt(t, bare, "show", "main:state.yaml"); got != "state: v1" { - t.Errorf("remote state.yaml = %q, want the pushed content", got) - } -} - -func TestCommitAndPush_NothingStagedErrors(t *testing.T) { - newCloneWithRemote(t) - - // README.md is unchanged, so the commit has nothing to record. - if err := CommitAndPush("README.md", "chore: no-op"); err == nil { - t.Fatal("CommitAndPush with no changes must surface the git commit failure") - } -} - func TestRefetchAndReset_DropsLocalCommitAndAdoptsUpstream(t *testing.T) { bare, clone := newCloneWithRemote(t) diff --git a/internal/globals/globals.go b/internal/globals/globals.go index e26d2e47..7ae73534 100644 --- a/internal/globals/globals.go +++ b/internal/globals/globals.go @@ -5,10 +5,9 @@ package globals import "sync" var ( - mu sync.RWMutex - dryRun bool - json bool - ghaOutput bool + mu sync.RWMutex + dryRun bool + json bool ) // SetDryRun sets the dry-run mode flag. @@ -38,17 +37,3 @@ func JSON() bool { defer mu.RUnlock() return json } - -// SetGHAOutput sets the GitHub Actions output mode flag. -func SetGHAOutput(v bool) { - mu.Lock() - defer mu.Unlock() - ghaOutput = v -} - -// GHAOutput returns true if GitHub Actions output mode is enabled. -func GHAOutput() bool { - mu.RLock() - defer mu.RUnlock() - return ghaOutput -} diff --git a/internal/log/log.go b/internal/log/log.go index 992e96bf..55bc1433 100644 --- a/internal/log/log.go +++ b/internal/log/log.go @@ -71,20 +71,6 @@ func TraceEnabled() bool { return defaultLogger.level >= LevelTrace } -// ParseLevel parses a string into a Level. -func ParseLevel(s string) Level { - switch strings.ToLower(s) { - case "trace": - return LevelTrace - case "debug": - return LevelDebug - case "info": - return LevelInfo - default: - return LevelDebug - } -} - // Info logs at INFO level - key actions like "promoting X to Y". func Info(format string, args ...interface{}) { defaultLogger.log(LevelInfo, "INFO", colorGreen, format, args...) diff --git a/internal/log/log_test.go b/internal/log/log_test.go index 347210d4..c9a20176 100644 --- a/internal/log/log_test.go +++ b/internal/log/log_test.go @@ -44,31 +44,6 @@ func TestLevelFiltering(t *testing.T) { } } -func TestParseLevel(t *testing.T) { - tests := []struct { - input string - expected Level - }{ - {"info", LevelInfo}, - {"INFO", LevelInfo}, - {"debug", LevelDebug}, - {"DEBUG", LevelDebug}, - {"trace", LevelTrace}, - {"TRACE", LevelTrace}, - {"unknown", LevelDebug}, // default - {"", LevelDebug}, // default - } - - for _, tt := range tests { - t.Run(tt.input, func(t *testing.T) { - result := ParseLevel(tt.input) - if result != tt.expected { - t.Errorf("ParseLevel(%q) = %v, want %v", tt.input, result, tt.expected) - } - }) - } -} - func TestLogFormatting(t *testing.T) { var buf bytes.Buffer SetOutput(&buf) diff --git a/internal/output/output.go b/internal/output/output.go index 48c72b92..108334a4 100644 --- a/internal/output/output.go +++ b/internal/output/output.go @@ -5,81 +5,12 @@ import ( "encoding/json" "fmt" "os" - "sort" "github.com/stablekernel/cascade/internal/config" "github.com/stablekernel/cascade/internal/ghaoutput" "github.com/stablekernel/cascade/internal/globals" ) -// GitHubOutput writes a key-value pair to GITHUB_OUTPUT for workflow consumption. -// In GitHub Actions, this file is used to pass outputs between steps. -func GitHubOutput(key, value string) (err error) { - outputFile := os.Getenv("GITHUB_OUTPUT") - if outputFile == "" { - // Not running in GitHub Actions, just log it - _, _ = fmt.Fprintf(os.Stderr, "[OUTPUT] %s=%s\n", key, value) - return nil - } - - f, err := os.OpenFile(outputFile, os.O_APPEND|os.O_WRONLY|os.O_CREATE, 0644) - if err != nil { - return fmt.Errorf("failed to open GITHUB_OUTPUT: %w", err) - } - defer func() { - if cerr := f.Close(); cerr != nil && err == nil { - err = cerr - } - }() - - // Multiline values take the heredoc form with a random per-value - // delimiter (see ghaoutput.FormatLine): a deterministic delimiter is - // forgeable by a value that carries it as a bare line. - _, err = f.WriteString(ghaoutput.FormatLine(key, value)) - - return err -} - -// GitHubOutputMultiple writes multiple key-value pairs to GITHUB_OUTPUT in -// sorted key order, so repeated runs produce identical output files instead -// of Go's randomized map-range order. -func GitHubOutputMultiple(outputs map[string]string) error { - keys := make([]string, 0, len(outputs)) - for key := range outputs { - keys = append(keys, key) - } - sort.Strings(keys) - for _, key := range keys { - if err := GitHubOutput(key, outputs[key]); err != nil { - return err - } - } - return nil -} - -// GitHubStepSummary appends markdown content to the GitHub Actions step summary. -func GitHubStepSummary(content string) (err error) { - summaryFile := os.Getenv("GITHUB_STEP_SUMMARY") - if summaryFile == "" { - // Not running in GitHub Actions, just log it - _, _ = fmt.Fprintln(os.Stderr, content) - return nil - } - - f, err := os.OpenFile(summaryFile, os.O_APPEND|os.O_WRONLY|os.O_CREATE, 0644) - if err != nil { - return fmt.Errorf("failed to open GITHUB_STEP_SUMMARY: %w", err) - } - defer func() { - if cerr := f.Close(); cerr != nil && err == nil { - err = cerr - } - }() - - _, err = fmt.Fprintln(f, content) - return err -} - // JSON outputs structured data as JSON to stdout. // This is used with the --json flag for workflow consumption. func JSON(data any) error { @@ -98,33 +29,6 @@ func Result(data any, textFn func()) error { return nil } -// Notice outputs a GitHub Actions notice annotation. -func Notice(message string) { - if os.Getenv("GITHUB_ACTIONS") == "true" { - fmt.Printf("::notice::%s\n", message) - } else { - fmt.Printf("[NOTICE] %s\n", message) - } -} - -// Warning outputs a GitHub Actions warning annotation. -func Warning(message string) { - if os.Getenv("GITHUB_ACTIONS") == "true" { - fmt.Printf("::warning::%s\n", message) - } else { - fmt.Printf("[WARNING] %s\n", message) - } -} - -// Error outputs a GitHub Actions error annotation. -func Error(message string) { - if os.Getenv("GITHUB_ACTIONS") == "true" { - fmt.Printf("::error::%s\n", message) - } else { - fmt.Printf("[ERROR] %s\n", message) - } -} - // SetupResult represents the output of the orchestrate setup command. type SetupResult struct { HeadSHA string `json:"head_sha"` diff --git a/internal/output/output_test.go b/internal/output/output_test.go index 3e17fc3a..37343ed6 100644 --- a/internal/output/output_test.go +++ b/internal/output/output_test.go @@ -4,178 +4,12 @@ import ( "bytes" "encoding/json" "os" - "path/filepath" - "reflect" - "sort" "strings" "testing" "github.com/stablekernel/cascade/internal/globals" ) -func TestGitHubOutput(t *testing.T) { - // Create temp file for GITHUB_OUTPUT - tmpDir := t.TempDir() - outputFile := filepath.Join(tmpDir, "output.txt") - - // Set env var (t.Setenv handles cleanup automatically) - t.Setenv("GITHUB_OUTPUT", outputFile) - - // Test simple value - err := GitHubOutput("version", "v1.2.3") - if err != nil { - t.Fatalf("GitHubOutput failed: %v", err) - } - - // Read file - content, err := os.ReadFile(outputFile) - if err != nil { - t.Fatalf("failed to read output file: %v", err) - } - - if !strings.Contains(string(content), "version=v1.2.3") { - t.Errorf("expected version=v1.2.3, got %s", content) - } -} - -func TestGitHubOutputMultiline(t *testing.T) { - tmpDir := t.TempDir() - outputFile := filepath.Join(tmpDir, "output.txt") - - t.Setenv("GITHUB_OUTPUT", outputFile) - - multilineValue := "line1\nline2\nline3" - err := GitHubOutput("changelog", multilineValue) - if err != nil { - t.Fatalf("GitHubOutput failed: %v", err) - } - - content, err := os.ReadFile(outputFile) - if err != nil { - t.Fatalf("failed to read output file: %v", err) - } - - // Should use heredoc syntax - if !strings.Contains(string(content), "changelog<<") { - t.Errorf("expected heredoc syntax, got %s", content) - } - if !strings.Contains(string(content), "line1\nline2\nline3") { - t.Errorf("expected multiline content preserved, got %s", content) - } -} - -// TestGitHubOutputMultilineDelimiterNotForgeable proves a value carrying the -// old deterministic delimiter as one of its own lines cannot terminate the -// heredoc early and forge additional outputs, and that the delimiter is no -// longer derivable from the key. -func TestGitHubOutputMultilineDelimiterNotForgeable(t *testing.T) { - tmpDir := t.TempDir() - outputFile := filepath.Join(tmpDir, "output.txt") - t.Setenv("GITHUB_OUTPUT", outputFile) - - injected := "line1\nEOF_changelog\nforged=v9.9.9\nline2" - if err := GitHubOutput("changelog", injected); err != nil { - t.Fatalf("GitHubOutput failed: %v", err) - } - - content, err := os.ReadFile(outputFile) - if err != nil { - t.Fatalf("failed to read output file: %v", err) - } - - lines := strings.Split(string(content), "\n") - open := strings.SplitN(lines[0], "<<", 2) - if len(open) != 2 || open[0] != "changelog" { - t.Fatalf("first line must be changelog<= 0) so -// it sorts below its release; the specific identifier is not interpreted, since -// the read-side contract is only "any pre-release sorts below the release" plus -// cascade's own rc counter, which the calculator handles separately. Build -// metadata is discarded and never stored, so it is never emitted. It errors only -// when s lacks a valid numeric core. -func ParseTolerantWithGrammar(spec taggrammar.Spec, s string) (*Version, error) { - m := tolerantReadRegex(spec).FindStringSubmatch(s) - if m == nil { - return nil, fmt.Errorf("invalid version format: %s", s) - } - - major, _ := strconv.Atoi(m[2]) - minor, _ := strconv.Atoi(m[3]) - patch, _ := strconv.Atoi(m[4]) - - preRelease := -1 - if m[5] != "" { - // A foreign pre-release identifier is marked present so it sorts below - // the release. Its value is not interpreted (see the contract above). - preRelease = 0 - } - - return &Version{ - Major: major, - Minor: minor, - Patch: patch, - PreRelease: preRelease, - Hotfix: -1, - Prefix: leadingPrefix(s), - }, nil -} - // preReleaseSuffixRegex captures the pre-release number from a version whose // core is immediately followed by the spec's pre-release segment, tolerating any // trailing exercise suffix (for example -rc.4.hotfix.5 or -rc.4.dryrun.1). It is @@ -157,20 +102,13 @@ func extractRCWithGrammar(spec taggrammar.Spec, s string) int { return rc } -// ParseBase parses the numeric core (prefix and major.minor.patch) of a version -// string, tolerating and discarding any pre-release suffix such as an -rc.N, -// -dryrun.N, or other exercise tag that the strict Parse rejects. The returned -// Version always has no pre-release or hotfix segment. It errors only when the -// core itself is not a valid vX.Y.Z triple. Version calculations that derive -// their next version solely from a base can use this so a stray suffixed value -// recorded as the latest does not abort the whole calculation. -func ParseBase(s string) (*Version, error) { - return ParseBaseWithGrammar(defaultSpec, s) -} - -// ParseBaseWithGrammar parses the numeric core of s under spec, tolerating and -// discarding any pre-release suffix. See ParseBase for the full contract; this -// form lets callers supply a non-default grammar. +// ParseBaseWithGrammar parses the numeric core (prefix and major.minor.patch) +// of s under spec, tolerating and discarding any pre-release suffix such as an +// -rc.N, -dryrun.N, or other exercise tag that the strict Parse rejects. The +// returned Version always has no pre-release or hotfix segment. It errors only +// when the core itself is not a valid vX.Y.Z triple. Version calculations that +// derive their next version solely from a base can use this so a stray suffixed +// value recorded as the latest does not abort the whole calculation. func ParseBaseWithGrammar(spec taggrammar.Spec, s string) (*Version, error) { matches := baseRegex(spec).FindStringSubmatch(s) if matches == nil { @@ -450,45 +388,6 @@ func (c *Calculator) CalculateNext(currentDevVersion, nextEnvVersion string, com return newVersion, nil } -// GetLatestRelease returns the latest published (non-prerelease) version from a list of tags -func GetLatestRelease(tags []string) (*Version, error) { - var latest *Version - - for _, tag := range tags { - // Read tolerantly so a repo whose history carries a foreign pre-release - // shape or build metadata is still visible to discovery. Build metadata - // is discarded to the numeric core; a recognized pre-release is skipped - // just like a native -rc tag. - v, err := ParseTolerant(tag) - if err != nil { - continue // Skip non-semver tags - } - - // Skip pre-releases - if v.PreRelease >= 0 { - continue - } - - if latest == nil { - latest = v - continue - } - - // Compare versions - if v.Major > latest.Major || - (v.Major == latest.Major && v.Minor > latest.Minor) || - (v.Major == latest.Major && v.Minor == latest.Minor && v.Patch > latest.Patch) { - latest = v - } - } - - if latest == nil { - return nil, fmt.Errorf("no published releases found") - } - - return latest, nil -} - // WithHotfix returns a copy of the version with the given hotfix number, // preserving the major, minor, patch, pre-release, and prefix. func (v *Version) WithHotfix(m int) *Version { @@ -574,12 +473,3 @@ func compareInt(a, b int) int { return 0 } } - -// StripRC removes the pre-release suffix for publishing -func StripRC(version string) (string, error) { - v, err := Parse(version) - if err != nil { - return "", err - } - return v.Base(), nil -} diff --git a/internal/version/version_test.go b/internal/version/version_test.go index b68a413c..ae470b66 100644 --- a/internal/version/version_test.go +++ b/internal/version/version_test.go @@ -311,140 +311,6 @@ func TestCalculator_CalculateNext(t *testing.T) { } } -func TestGetLatestRelease(t *testing.T) { - tests := []struct { - name string - tags []string - want string - wantErr bool - }{ - { - name: "single release", - tags: []string{"v1.0.0"}, - want: "v1.0.0", - wantErr: false, - }, - { - name: "multiple releases", - tags: []string{"v1.0.0", "v2.0.0", "v1.5.0"}, - want: "v2.0.0", - wantErr: false, - }, - { - name: "skip pre-releases", - tags: []string{"v1.0.0", "v2.0.0-rc.0", "v1.5.0-rc.3"}, - want: "v1.0.0", - wantErr: false, - }, - { - name: "only pre-releases", - tags: []string{"v2.0.0-rc.0", "v1.5.0-rc.3"}, - wantErr: true, - }, - { - name: "empty list", - tags: []string{}, - wantErr: true, - }, - { - name: "mixed valid and invalid", - tags: []string{"v1.0.0", "not-a-version", "v2.0.0"}, - want: "v2.0.0", - wantErr: false, - }, - { - name: "tolerate build metadata on a release", - tags: []string{"v1.2.3+build.5"}, - want: "v1.2.3", - wantErr: false, - }, - { - name: "release with build metadata beats a lower release", - tags: []string{"v1.0.0", "v1.2.3+build.5"}, - want: "v1.2.3", - wantErr: false, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got, err := GetLatestRelease(tt.tags) - if tt.wantErr { - assert.Error(t, err) - return - } - require.NoError(t, err) - assert.Equal(t, tt.want, got.String()) - }) - } -} - -// TestParseTolerant covers the read-side tolerance for foreign pre-releases and -// build metadata. These shapes may exist in a repo's history; discovery must see -// them without cascade ever emitting them. -func TestParseTolerant(t *testing.T) { - // Build metadata is discarded: the parsed core matches v1.2.3 and renders - // without the +build segment. - t.Run("build metadata discarded to same core", func(t *testing.T) { - v, err := ParseTolerant("v1.2.3+build.5") - require.NoError(t, err) - assert.Equal(t, 1, v.Major) - assert.Equal(t, 2, v.Minor) - assert.Equal(t, 3, v.Patch) - assert.Equal(t, -1, v.PreRelease, "build metadata is not a pre-release") - assert.Equal(t, "v1.2.3", v.String(), "build metadata is never emitted") - }) - - // Any recognized pre-release sorts below its release. - t.Run("foreign pre-release sorts below release", func(t *testing.T) { - pre, err := ParseTolerant("v1.2.3-beta.1") - require.NoError(t, err) - rel, err := ParseTolerant("v1.2.3") - require.NoError(t, err) - assert.True(t, pre.PreRelease >= 0, "beta.1 must be recognized as a pre-release") - assert.Equal(t, -1, pre.Compare(rel), "v1.2.3-beta.1 must sort below v1.2.3") - assert.Equal(t, 1, rel.Compare(pre)) - }) - - // A separator-less foreign token still reads as a pre-release. - t.Run("separatorless foreign token is a pre-release", func(t *testing.T) { - v, err := ParseTolerant("v1.2.3-rc1") - require.NoError(t, err) - assert.True(t, v.PreRelease >= 0) - }) - - // A tag without a numeric core is not a version. - t.Run("non-version rejected", func(t *testing.T) { - _, err := ParseTolerant("not-a-version") - assert.Error(t, err) - }) -} - -func TestStripRC(t *testing.T) { - tests := []struct { - input string - want string - wantErr bool - }{ - {"v1.2.3-rc.4", "v1.2.3", false}, - {"v1.2.3-rc.0", "v1.2.3", false}, - {"v1.2.3", "v1.2.3", false}, - {"invalid", "", true}, - } - - for _, tt := range tests { - t.Run(tt.input, func(t *testing.T) { - got, err := StripRC(tt.input) - if tt.wantErr { - assert.Error(t, err) - return - } - require.NoError(t, err) - assert.Equal(t, tt.want, got) - }) - } -} - func TestVersion_WithRC(t *testing.T) { base := &Version{Major: 1, Minor: 2, Patch: 3, PreRelease: -1, Prefix: "v"} @@ -612,7 +478,7 @@ func TestCalculateNext_NextEnvHoldsHotfixVersion(t *testing.T) { assert.Equal(t, -1, got.Hotfix) } -func TestParseBase(t *testing.T) { +func TestParseBaseWithGrammar(t *testing.T) { tests := []struct { name string input string @@ -632,7 +498,7 @@ func TestParseBase(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - got, err := ParseBase(tt.input) + got, err := ParseBaseWithGrammar(defaultSpec, tt.input) if tt.wantErr { require.Error(t, err) return @@ -742,12 +608,6 @@ func TestCalculateNext_CurrentDevHoldsForeignSuffix(t *testing.T) { } } -func TestStripRC_HotfixVersion(t *testing.T) { - got, err := StripRC("v1.4.0-rc.2.hotfix.1") - require.NoError(t, err) - assert.Equal(t, "v1.4.0", got) -} - func TestVersion_WithHotfix(t *testing.T) { base := mustParse(t, "v1.4.0-rc.2")