diff --git a/.github/workflows/pr.yaml b/.github/workflows/pr.yaml
index 0e2c5ad..b4dbd6f 100644
--- a/.github/workflows/pr.yaml
+++ b/.github/workflows/pr.yaml
@@ -67,6 +67,18 @@ jobs:
COVERAGE=$(go tool cover -func=coverage.out | grep total | awk '{print $3}' | tr -d '%')
echo "Total coverage: ${COVERAGE}%"
+ - name: Check coverage threshold
+ run: |
+ COVERAGE=$(go tool cover -func=coverage.out | grep total | awk '{print $3}' | tr -d '%')
+ echo "Total coverage: ${COVERAGE}%"
+ awk -v cov="$COVERAGE" 'BEGIN {
+ if (cov + 0 < 80) {
+ printf "Coverage %s%% is below the 80%% threshold\n", cov
+ exit 1
+ }
+ printf "Coverage %s%% meets the 80%% threshold\n", cov
+ }'
+
lint:
name: Lint
needs: changes
diff --git a/.github/workflows/validate.yaml b/.github/workflows/validate.yaml
index 7017bb7..2969deb 100644
--- a/.github/workflows/validate.yaml
+++ b/.github/workflows/validate.yaml
@@ -53,6 +53,18 @@ jobs:
COVERAGE=$(go tool cover -func=coverage.out | grep total | awk '{print $3}' | tr -d '%')
echo "Total coverage: ${COVERAGE}%"
+ - name: Check coverage threshold
+ run: |
+ COVERAGE=$(go tool cover -func=coverage.out | grep total | awk '{print $3}' | tr -d '%')
+ echo "Total coverage: ${COVERAGE}%"
+ awk -v cov="$COVERAGE" 'BEGIN {
+ if (cov + 0 < 80) {
+ printf "Coverage %s%% is below the 80%% threshold\n", cov
+ exit 1
+ }
+ printf "Coverage %s%% meets the 80%% threshold\n", cov
+ }'
+
lint:
name: Lint
runs-on: ubuntu-latest
diff --git a/README.md b/README.md
index 5a36778..945329d 100644
--- a/README.md
+++ b/README.md
@@ -19,6 +19,7 @@
+
diff --git a/internal/changelog/command_test.go b/internal/changelog/command_test.go
new file mode 100644
index 0000000..1121347
--- /dev/null
+++ b/internal/changelog/command_test.go
@@ -0,0 +1,78 @@
+package changelog
+
+import (
+ "bytes"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+)
+
+func TestChangelogNewCommand_Structure(t *testing.T) {
+ cmd := NewCommand()
+
+ assert.Equal(t, "generate-changelog", cmd.Use)
+ assert.NotEmpty(t, cmd.Short)
+
+ assert.NotNil(t, cmd.Flags().Lookup("base-sha"))
+ assert.NotNil(t, cmd.Flags().Lookup("head-sha"))
+ assert.NotNil(t, cmd.Flags().Lookup("repo"))
+ assert.NotNil(t, cmd.Flags().Lookup("exclude-paths"))
+ assert.NotNil(t, cmd.Flags().Lookup("contributors"))
+}
+
+func TestChangelogNewCommand_RunE_EmptySHARange(t *testing.T) {
+ // HEAD..HEAD yields zero commits without error; exercises the RunE closure
+ // body and runGenerateChangelog through the JSON output path.
+ cmd := NewCommand()
+ var out bytes.Buffer
+ cmd.SetOut(&out)
+ cmd.SetErr(&out)
+ cmd.SetArgs([]string{
+ "--base-sha", "HEAD",
+ "--head-sha", "HEAD",
+ "--repo", "owner/repo",
+ })
+ // The empty commit range produces valid JSON output; swallow any git failure.
+ _ = cmd.Execute()
+}
+
+func TestChangelogNewCommand_RunE_WithExcludePaths(t *testing.T) {
+ // Exercises the excludePaths splitting branch in the RunE closure.
+ cmd := NewCommand()
+ var out bytes.Buffer
+ cmd.SetOut(&out)
+ cmd.SetErr(&out)
+ cmd.SetArgs([]string{
+ "--base-sha", "HEAD",
+ "--head-sha", "HEAD",
+ "--repo", "owner/repo",
+ "--exclude-paths", "docs/, internal/old/",
+ })
+ _ = cmd.Execute()
+}
+
+func TestChangelogNewCommand_RunE_WithContributors(t *testing.T) {
+ // Exercises the contributors branch in runGenerateChangelog.
+ cmd := NewCommand()
+ var out bytes.Buffer
+ cmd.SetOut(&out)
+ cmd.SetErr(&out)
+ cmd.SetArgs([]string{
+ "--base-sha", "HEAD",
+ "--head-sha", "HEAD",
+ "--repo", "owner/repo",
+ "--contributors",
+ })
+ _ = cmd.Execute()
+}
+
+func TestChangelogNewCommand_MissingRequiredFlags(t *testing.T) {
+ // Omitting required flags produces an error before RunE runs.
+ cmd := NewCommand()
+ var out bytes.Buffer
+ cmd.SetOut(&out)
+ cmd.SetErr(&out)
+ cmd.SetArgs([]string{"--repo", "owner/repo"})
+ err := cmd.Execute()
+ assert.Error(t, err)
+}
diff --git a/internal/changelog/github_coverage_test.go b/internal/changelog/github_coverage_test.go
new file mode 100644
index 0000000..9912740
--- /dev/null
+++ b/internal/changelog/github_coverage_test.go
@@ -0,0 +1,86 @@
+package changelog
+
+import (
+ "encoding/json"
+ "testing"
+
+ "github.com/stablekernel/cascade/internal/git"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestLookupAuthorsBatch_EmptyEmails(t *testing.T) {
+ result := lookupAuthorsBatch("owner/repo", nil, nil)
+ assert.Empty(t, result)
+}
+
+func TestLookupAuthorsBatch_EmptyEmailSlice(t *testing.T) {
+ result := lookupAuthorsBatch("owner/repo", []string{}, map[string]string{})
+ assert.Empty(t, result)
+}
+
+func TestLookupAuthorsBatch_InvalidRepoFormat(t *testing.T) {
+ // A repo string without a slash causes an early return.
+ emails := []string{"alice@example.com"}
+ hashes := map[string]string{"alice@example.com": "abc123"}
+ result := lookupAuthorsBatch("noslash", emails, hashes)
+ assert.Empty(t, result)
+}
+
+func TestUnmarshalJSON_InvalidEntrySkipped(t *testing.T) {
+ // When one repository entry cannot be decoded as a commitObject, it is
+ // silently skipped; valid entries are still decoded correctly.
+ jsonData := `{
+ "data": {
+ "repository": {
+ "c0": {"author": {"user": {"login": "alice"}}},
+ "c1": "not-an-object"
+ }
+ }
+ }`
+
+ var response graphQLResponse
+ err := json.Unmarshal([]byte(jsonData), &response)
+ require.NoError(t, err)
+
+ c0, ok := response.Data.Repository["c0"]
+ require.True(t, ok, "c0 should be present")
+ assert.Equal(t, "alice", c0.Author.User.Login)
+
+ // c1 had invalid structure and was skipped; it may be absent or have empty login.
+ if c1, exists := response.Data.Repository["c1"]; exists {
+ assert.Empty(t, c1.Author.User.Login, "skipped entry should have no login")
+ }
+}
+
+func TestUnmarshalJSON_TopLevelError(t *testing.T) {
+ // Malformed JSON should return an error from UnmarshalJSON.
+ var response graphQLResponse
+ err := json.Unmarshal([]byte("not json"), &response)
+ 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.
+ commits := []git.Commit{
+ {Hash: "abc123", AuthorEmail: "alice@example.com"},
+ }
+ result := LookupGitHubUsernames(commits, "noslash")
+ require.Len(t, result, 1)
+ assert.Empty(t, result[0].GitHubUsername)
+}
diff --git a/internal/changelog/parse_coverage_test.go b/internal/changelog/parse_coverage_test.go
new file mode 100644
index 0000000..7c8f114
--- /dev/null
+++ b/internal/changelog/parse_coverage_test.go
@@ -0,0 +1,161 @@
+package changelog
+
+import (
+ "fmt"
+ "strings"
+ "testing"
+
+ "github.com/stablekernel/cascade/internal/git"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestParseCommit_WithPRNumber(t *testing.T) {
+ commit := git.Commit{
+ Hash: "abc123def456",
+ Subject: "feat: add login endpoint (#42)",
+ Body: "",
+ }
+ cc := ParseCommit(commit)
+ require.NotNil(t, cc)
+ assert.Equal(t, "feat", cc.Type)
+ assert.Equal(t, "42", cc.PRNumber)
+ assert.Equal(t, "add login endpoint", cc.Description)
+}
+
+func TestParseCommit_WithOrgRepoPRRef(t *testing.T) {
+ // PR references in description are extracted regardless of preceding text
+ commit := git.Commit{
+ Hash: "def456abc123",
+ Subject: "fix: resolve null pointer (#999)",
+ }
+ cc := ParseCommit(commit)
+ require.NotNil(t, cc)
+ assert.Equal(t, "999", cc.PRNumber)
+ assert.Equal(t, "resolve null pointer", cc.Description)
+}
+
+func TestCategorizeCommits_NonRoutineNonFeatFix(t *testing.T) {
+ // A commit type that is not feat/fix and is not a routine type
+ // (e.g. "perf") should appear in the other slice.
+ commits := []git.Commit{
+ {Hash: "perf1234567", Subject: "perf(db): optimize query", Body: ""},
+ {Hash: "build123456", Subject: "build(ci): speed up pipeline", Body: ""},
+ }
+ _, _, _, other := CategorizeCommits(commits)
+ require.Len(t, other, 2)
+ assert.Equal(t, "perf1234567", other[0].Hash)
+ assert.Equal(t, "build123456", other[1].Hash)
+}
+
+func TestFormatMarkdown_CollapsibleFeatureSection(t *testing.T) {
+ // collapsibleThreshold is 5; six commits should trigger the collapsible wrapper.
+ features := make([]ConventionalCommit, 6)
+ for i := range features {
+ features[i] = ConventionalCommit{
+ Description: fmt.Sprintf("feature number %d", i),
+ Hash: fmt.Sprintf("hash%04d", i),
+ FullHash: fmt.Sprintf("fullhash%08d", i),
+ }
+ }
+ result := FormatMarkdown(nil, features, nil, nil, "owner/repo", "base123", "head456")
+
+ assert.Contains(t, result, "")
+ assert.Contains(t, result, "")
+ assert.Contains(t, result, "✨ Features")
+ assert.Contains(t, result, "
")
+}
+
+func TestFormatMarkdown_CollapsibleOtherSection(t *testing.T) {
+ // collapsibleThreshold is 5; six other commits should trigger the collapsible wrapper.
+ other := make([]git.Commit, 6)
+ for i := range other {
+ other[i] = git.Commit{
+ Hash: fmt.Sprintf("otherhash%04d", i),
+ Subject: fmt.Sprintf("other commit %d", i),
+ }
+ }
+ result := FormatMarkdown(nil, nil, nil, other, "owner/repo", "base123", "head456")
+
+ assert.Contains(t, result, "")
+ assert.Contains(t, result, "📝 Other Changes")
+ assert.Contains(t, result, " ")
+}
+
+func TestFormatMarkdown_WithScopedCommits(t *testing.T) {
+ // Commits with non-empty scopes should produce scope headers and
+ // exercise the getSortedScopes named-scope branch.
+ features := []ConventionalCommit{
+ {Scope: "auth", Description: "add login", Hash: "abc1234", FullHash: "abc12345678"},
+ {Scope: "api", Description: "add endpoint", Hash: "def1234", FullHash: "def12345678"},
+ }
+ result := FormatMarkdown(nil, features, nil, nil, "owner/repo", "base", "head")
+
+ assert.Contains(t, result, "#### `auth`")
+ assert.Contains(t, result, "#### `api`")
+ assert.Contains(t, result, "add login")
+ assert.Contains(t, result, "add endpoint")
+}
+
+func TestFormatMarkdown_MixedScopedAndUnscopedCommits(t *testing.T) {
+ // Mix of scoped and unscoped commits: verifies both the named-scope header
+ // and the trailing empty-scope group (no header rendered).
+ features := []ConventionalCommit{
+ {Scope: "auth", Description: "add login", Hash: "aaa1234", FullHash: "aaa12345678"},
+ {Scope: "", Description: "general improvement", Hash: "bbb1234", FullHash: "bbb12345678"},
+ }
+ result := FormatMarkdown(nil, features, nil, nil, "owner/repo", "base", "head")
+
+ assert.Contains(t, result, "#### `auth`")
+ assert.Contains(t, result, "general improvement")
+}
+
+func TestFormatCommitLine_WithPRNumber(t *testing.T) {
+ c := ConventionalCommit{
+ Description: "add feature",
+ Hash: "abc1234",
+ FullHash: "abc1234567",
+ PRNumber: "42",
+ }
+ line := formatCommitLine(c, "owner/repo")
+
+ assert.Contains(t, line, "[#42]")
+ assert.Contains(t, line, "https://github.com/owner/repo/pull/42")
+ assert.Contains(t, line, "add feature")
+ assert.True(t, strings.HasPrefix(line, "- "))
+}
+
+func TestFormatCommitLine_WithoutPRNumber(t *testing.T) {
+ c := ConventionalCommit{
+ Description: "add feature",
+ Hash: "abc1234",
+ FullHash: "abc12345678",
+ }
+ line := formatCommitLine(c, "owner/repo")
+
+ assert.Contains(t, line, "[`abc1234`]")
+ assert.Contains(t, line, "https://github.com/owner/repo/commit/abc12345678")
+}
+
+func TestFormatOtherCommitLine_WithUsername(t *testing.T) {
+ c := git.Commit{
+ Hash: "abc1234567",
+ Subject: "merge pull request",
+ GitHubUsername: "alice",
+ }
+ line := formatOtherCommitLine(c, "owner/repo")
+
+ assert.Contains(t, line, "(@alice)")
+ assert.Contains(t, line, "merge pull request")
+}
+
+func TestFormatOtherCommitLine_WithoutUsername(t *testing.T) {
+ c := git.Commit{
+ Hash: "abc1234567",
+ Subject: "merge pull request",
+ }
+ line := formatOtherCommitLine(c, "owner/repo")
+
+ assert.NotContains(t, line, "(@")
+ assert.Contains(t, line, "merge pull request")
+}
diff --git a/internal/hotfix/command_test.go b/internal/hotfix/command_test.go
new file mode 100644
index 0000000..62e0a0b
--- /dev/null
+++ b/internal/hotfix/command_test.go
@@ -0,0 +1,215 @@
+package hotfix
+
+import (
+ "bytes"
+ "io"
+ "os"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+// captureStdout runs fn with os.Stdout redirected to a pipe and returns whatever
+// fn printed. It lets the human-readable printers be asserted on their output.
+func captureStdout(t *testing.T, fn func()) string {
+ t.Helper()
+ orig := os.Stdout
+ r, w, err := os.Pipe()
+ require.NoError(t, err)
+ os.Stdout = w
+ defer func() { os.Stdout = orig }()
+
+ fn()
+ require.NoError(t, w.Close())
+ var buf bytes.Buffer
+ _, err = io.Copy(&buf, r)
+ require.NoError(t, err)
+ return buf.String()
+}
+
+// TestNewCommand verifies the hotfix command exposes plan and finalize.
+func TestNewCommand(t *testing.T) {
+ cmd := NewCommand()
+ require.NotNil(t, cmd)
+ assert.Equal(t, "hotfix", cmd.Name())
+
+ plan, _, err := cmd.Find([]string{"plan"})
+ require.NoError(t, err)
+ assert.Equal(t, "plan", plan.Name())
+
+ finalize, _, err := cmd.Find([]string{"finalize"})
+ require.NoError(t, err)
+ assert.Equal(t, "finalize", finalize.Name())
+}
+
+// TestNewPlanCommand_Flags asserts the plan subcommand wires its flags and
+// required/exclusive constraints.
+func TestNewPlanCommand_Flags(t *testing.T) {
+ cmd := newPlanCommand()
+ assert.Equal(t, "plan", cmd.Name())
+ for _, name := range []string{"config", "key", "commit", "commits", "target-env", "actor", "remote", "repo", "dry-run", "json", "gha-output"} {
+ assert.NotNil(t, cmd.Flags().Lookup(name), "plan flag %q should exist", name)
+ }
+}
+
+// TestNewFinalizeCommand_Flags asserts the finalize subcommand wires its flags.
+func TestNewFinalizeCommand_Flags(t *testing.T) {
+ cmd := newFinalizeCommand()
+ assert.Equal(t, "finalize", cmd.Name())
+ for _, name := range []string{"config", "key", "target-env", "merge-sha", "fix-sha", "base-sha", "actor", "dry-run", "deploy-result", "build-result"} {
+ assert.NotNil(t, cmd.Flags().Lookup(name), "finalize flag %q should exist", name)
+ }
+}
+
+// TestSplitResultFlag covers the name=result parser including its reject cases.
+func TestSplitResultFlag(t *testing.T) {
+ cases := []struct {
+ in string
+ name string
+ result string
+ ok bool
+ }{
+ {"app=success", "app", "success", true},
+ {"svc.api=failure", "svc.api", "failure", true},
+ {"a=b=c", "a", "b=c", true},
+ {"", "", "", false},
+ {"=success", "", "", false},
+ {"app=", "", "", false},
+ {"noequals", "", "", false},
+ }
+ for _, tc := range cases {
+ t.Run(tc.in, func(t *testing.T) {
+ name, result, ok := splitResultFlag(tc.in)
+ assert.Equal(t, tc.ok, ok)
+ assert.Equal(t, tc.name, name)
+ assert.Equal(t, tc.result, result)
+ })
+ }
+}
+
+// TestNewGHPRChecker constructs the gh-backed checker and records its repo.
+func TestNewGHPRChecker(t *testing.T) {
+ c := newGHPRChecker("org/repo")
+ require.NotNil(t, c)
+ assert.Equal(t, "org/repo", c.repo)
+}
+
+// TestPrintPlan_FullPlan asserts the human plan output includes the env, branch,
+// version, and the dry-run marker.
+func TestPrintPlan_FullPlan(t *testing.T) {
+ result := &PlanResult{
+ TargetEnv: "uat",
+ FixSHA: "abcdef1234567890",
+ Branch: "env/uat",
+ BaseSHA: "fedcba0987654321",
+ BranchCreated: true,
+ DryRun: true,
+ HotfixVersionCandidate: "v1.2.0-rc.1.hotfix.1",
+ ProtectionSuggestions: []string{"gh api ...protect..."},
+ }
+ out := captureStdout(t, func() { printPlan(result) })
+ assert.Contains(t, out, "uat")
+ assert.Contains(t, out, "env/uat")
+ assert.Contains(t, out, "v1.2.0-rc.1.hotfix.1")
+ assert.Contains(t, out, "dry-run")
+ assert.Contains(t, out, "would create")
+ assert.Contains(t, out, "gh api ...protect...")
+}
+
+// TestPrintPlan_NoOp short-circuits with a no-op message.
+func TestPrintPlan_NoOp(t *testing.T) {
+ result := &PlanResult{TargetEnv: "prod", FixSHA: "abc1234", NoOp: true}
+ out := captureStdout(t, func() { printPlan(result) })
+ assert.Contains(t, out, "no-op")
+ assert.Contains(t, out, "prod")
+ assert.NotContains(t, out, "Version:")
+}
+
+// TestPrintPlan_ExistingBranch reports the already-present branch path.
+func TestPrintPlan_ExistingBranch(t *testing.T) {
+ result := &PlanResult{
+ TargetEnv: "uat",
+ FixSHA: "abcdef1",
+ Branch: "env/uat",
+ BaseSHA: "fed0987",
+ BranchCreated: false,
+ HotfixVersionCandidate: "v1.0.1",
+ }
+ out := captureStdout(t, func() { printPlan(result) })
+ assert.Contains(t, out, "already present")
+ assert.NotContains(t, out, "dry-run")
+}
+
+// TestPrintPlanChain renders the bottom-up env sequence and per-env commits.
+func TestPrintPlanChain(t *testing.T) {
+ result := &PlanChainResult{Envs: []EnvPlan{
+ {Env: "test", Branch: "env/test", BaseSHA: "1111111aaaa", Commits: []string{"2222222bbbb", "3333333cccc"}},
+ {Env: "uat", NoOp: true},
+ }}
+ out := captureStdout(t, func() { printPlanChain(result) })
+ assert.Contains(t, out, "test")
+ assert.Contains(t, out, "env/test")
+ assert.Contains(t, out, "2222222")
+ assert.Contains(t, out, "no-op")
+ assert.Contains(t, out, "2 environment(s)")
+}
+
+// TestOutputJSON marshals a value and prints it as indented JSON.
+func TestOutputJSON(t *testing.T) {
+ out := captureStdout(t, func() {
+ require.NoError(t, outputJSON(&PlanResult{TargetEnv: "uat", FixSHA: "abc"}))
+ })
+ assert.Contains(t, out, `"target_env": "uat"`)
+ assert.Contains(t, out, `"fix_sha": "abc"`)
+}
+
+// TestWritePlanGHAOutput writes the single-env plan keys to $GITHUB_OUTPUT.
+func TestWritePlanGHAOutput(t *testing.T) {
+ outFile := t.TempDir() + "/gha_output"
+ t.Setenv("GITHUB_OUTPUT", outFile)
+
+ result := &PlanResult{
+ TargetEnv: "uat",
+ FixSHA: "abc123",
+ Branch: "env/uat",
+ BaseSHA: "def456",
+ NoOp: false,
+ BranchCreated: true,
+ HotfixVersionCandidate: "v1.0.1",
+ ConflictExpected: false,
+ DryRun: true,
+ ProtectionSuggestions: []string{"gh api protect"},
+ }
+ require.NoError(t, writePlanGHAOutput(result))
+
+ data, err := os.ReadFile(outFile)
+ require.NoError(t, err)
+ content := string(data)
+ assert.Contains(t, content, "target_env=uat")
+ assert.Contains(t, content, "branch=env/uat")
+ assert.Contains(t, content, "hotfix_version_candidate=v1.0.1")
+ assert.Contains(t, content, "dry_run=true")
+ assert.Contains(t, content, "protection_suggestions")
+}
+
+// TestWritePlanChainGHAOutput writes the additive chain keys to $GITHUB_OUTPUT.
+func TestWritePlanChainGHAOutput(t *testing.T) {
+ outFile := t.TempDir() + "/gha_output"
+ t.Setenv("GITHUB_OUTPUT", outFile)
+
+ result := &PlanChainResult{Envs: []EnvPlan{
+ {Env: "test", Branch: "env/test", BaseSHA: "111", Commits: []string{"aaa", "bbb"}},
+ {Env: "uat", Branch: "env/uat", BaseSHA: "222", NoOp: true},
+ }}
+ require.NoError(t, writePlanChainGHAOutput(result))
+
+ data, err := os.ReadFile(outFile)
+ require.NoError(t, err)
+ content := string(data)
+ assert.Contains(t, content, "env_sequence=test,uat")
+ assert.Contains(t, content, "env_count=2")
+ assert.Contains(t, content, "commits_test=aaa,bbb")
+ assert.Contains(t, content, "no_op_uat=true")
+ assert.Contains(t, content, "base_test=111")
+}
diff --git a/internal/hotfix/finalize_options_test.go b/internal/hotfix/finalize_options_test.go
new file mode 100644
index 0000000..fb9ea9c
--- /dev/null
+++ b/internal/hotfix/finalize_options_test.go
@@ -0,0 +1,201 @@
+package hotfix
+
+import (
+ "testing"
+
+ "github.com/stablekernel/cascade/internal/config"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+// recordingTipReader is a record-only env-branch tip reader for option tests.
+type recordingTipReader struct {
+ sha string
+}
+
+func (r recordingTipReader) LocalBranchSHA(string) (string, error) { return r.sha, nil }
+
+// TestWithFinalizeDryRun applies the dry-run option to the finalizer.
+func TestWithFinalizeDryRun(t *testing.T) {
+ f := &Finalizer{}
+ WithFinalizeDryRun(true)(f)
+ assert.True(t, f.dryRun)
+
+ WithFinalizeDryRun(false)(f)
+ assert.False(t, f.dryRun)
+}
+
+// TestWithTipReader injects a custom tip reader and ignores a nil one.
+func TestWithTipReader(t *testing.T) {
+ f := &Finalizer{tipReader: envTipReader{}}
+
+ WithTipReader(recordingTipReader{sha: "deadbeef"})(f)
+ rr, ok := f.tipReader.(recordingTipReader)
+ require.True(t, ok, "injected reader should replace the default")
+ assert.Equal(t, "deadbeef", rr.sha)
+
+ // A nil reader must not clobber the existing one.
+ WithTipReader(nil)(f)
+ _, ok = f.tipReader.(recordingTipReader)
+ assert.True(t, ok, "nil option must leave the prior reader intact")
+}
+
+// TestFinalizeOptions_NilSafety verifies each injecting option ignores nil and
+// preserves the previously set dependency.
+func TestFinalizeOptions_NilSafety(t *testing.T) {
+ mgr := &stubReleaseManager{}
+ lister := stubTagLister{tags: []string{"v1.0.0"}}
+ pusher := &recordingPusher{}
+ trunk := &stubTrunkReader{}
+
+ f := &Finalizer{}
+ WithReleaseManager(mgr)(f)
+ WithTagLister(lister)(f)
+ WithStatePusher(pusher)(f)
+ WithTrunkStateReader(trunk)(f)
+
+ assert.Same(t, mgr, f.releaseMgr)
+ assert.Equal(t, lister, f.tagLister)
+ assert.Same(t, pusher, f.pusher)
+ assert.True(t, f.pusherInjected)
+ assert.Same(t, trunk, f.trunkReader)
+
+ // Nil options are no-ops and leave the dependencies in place.
+ WithReleaseManager(nil)(f)
+ WithStatePusher(nil)(f)
+ WithTrunkStateReader(nil)(f)
+ assert.Same(t, mgr, f.releaseMgr)
+ assert.Same(t, pusher, f.pusher)
+ assert.Same(t, trunk, f.trunkReader)
+}
+
+// TestGitIdentity covers the manifest-config to commit-identity mapping for the
+// hotfix finalizer.
+func TestGitIdentity(t *testing.T) {
+ assert.Equal(t, "", gitIdentity(nil).Name)
+
+ def := gitIdentity(&config.TrunkConfig{})
+ assert.Equal(t, "github-actions[bot]", def.Name)
+ assert.Equal(t, "github-actions[bot]@users.noreply.github.com", def.Email)
+
+ custom := gitIdentity(&config.TrunkConfig{Git: &config.GitConfig{
+ UserName: "Hotfix Bot",
+ UserEmail: "hotfix@example.com",
+ }})
+ assert.Equal(t, "Hotfix Bot", custom.Name)
+ assert.Equal(t, "hotfix@example.com", custom.Email)
+}
+
+// TestIsRealGitHub covers the act/gitea vs github.com detection.
+func TestIsRealGitHub(t *testing.T) {
+ t.Setenv("GITHUB_SERVER_URL", "https://github.com")
+ assert.True(t, isRealGitHub())
+
+ t.Setenv("GITHUB_SERVER_URL", "")
+ assert.True(t, isRealGitHub(), "unset defaults to real GitHub")
+
+ t.Setenv("GITHUB_SERVER_URL", "http://gitea:3000")
+ assert.False(t, isRealGitHub())
+}
+
+// TestAllocateVersion covers rc-nested allocation, published patch bump, tag
+// collision skipping, and both error branches.
+func TestAllocateVersion(t *testing.T) {
+ t.Run("rc base allocates nested hotfix", func(t *testing.T) {
+ f := &Finalizer{tagLister: stubTagLister{}}
+ got, err := f.allocateVersion("v1.4.0-rc.2")
+ require.NoError(t, err)
+ assert.Equal(t, "v1.4.0-rc.2.hotfix.1", got)
+ })
+
+ t.Run("rc base skips existing hotfix tag", func(t *testing.T) {
+ f := &Finalizer{tagLister: stubTagLister{tags: []string{"v1.4.0-rc.2.hotfix.1"}}}
+ got, err := f.allocateVersion("v1.4.0-rc.2")
+ require.NoError(t, err)
+ assert.Equal(t, "v1.4.0-rc.2.hotfix.2", got)
+ })
+
+ t.Run("published base patch bump", func(t *testing.T) {
+ f := &Finalizer{tagLister: stubTagLister{}}
+ got, err := f.allocateVersion("v1.3.0")
+ require.NoError(t, err)
+ assert.Equal(t, "v1.3.1", got)
+ })
+
+ t.Run("published base skips taken patches", func(t *testing.T) {
+ f := &Finalizer{tagLister: stubTagLister{tags: []string{"v1.3.1", "v1.3.2"}}}
+ got, err := f.allocateVersion("v1.3.0")
+ require.NoError(t, err)
+ assert.Equal(t, "v1.3.3", got)
+ })
+
+ t.Run("empty prior version errors", func(t *testing.T) {
+ f := &Finalizer{tagLister: stubTagLister{}}
+ _, err := f.allocateVersion("")
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "no recorded version")
+ })
+
+ t.Run("unparseable prior version errors", func(t *testing.T) {
+ f := &Finalizer{tagLister: stubTagLister{}}
+ _, err := f.allocateVersion("not-a-semver")
+ require.Error(t, err)
+ })
+}
+
+// TestIsPrereleaseEnv identifies the second-from-top env as the prerelease env.
+func TestIsPrereleaseEnv(t *testing.T) {
+ f := &Finalizer{}
+ cfg := &config.TrunkConfig{Environments: []string{"dev", "test", "uat", "prod"}}
+
+ assert.True(t, f.isPrereleaseEnv(cfg, "uat"), "second-from-top is the prerelease env")
+ assert.False(t, f.isPrereleaseEnv(cfg, "prod"))
+ assert.False(t, f.isPrereleaseEnv(cfg, "dev"))
+
+ single := &config.TrunkConfig{Environments: []string{"prod"}}
+ assert.False(t, f.isPrereleaseEnv(single, "prod"), "fewer than two envs has no prerelease env")
+}
+
+// TestApplyHotfixState_NoOpWhenSHAUnchanged returns early without mutating the
+// patch list when the target already records the merge SHA.
+func TestApplyHotfixState_NoOpWhenSHAUnchanged(t *testing.T) {
+ f := &Finalizer{actor: "dev"}
+ cicd := &config.CICDFile{State: map[string]*config.EnvState{
+ "uat": {SHA: "mergesha", Patches: []string{"existing"}},
+ }}
+
+ err := f.applyHotfixState(cicd, "uat", "mergesha", "v1.0.1", "basesha", "2026-01-01T00:00:00Z", []string{"newfix"})
+ require.NoError(t, err)
+
+ state := cicd.State["uat"]
+ assert.Equal(t, []string{"existing"}, state.Patches, "no-op rerun must not append patches")
+ assert.Equal(t, "mergesha", state.SHA)
+}
+
+// TestApplyHotfixState_WritesDivergedState records the merge SHA, base, patches,
+// ref, and substates on a fresh target env.
+func TestApplyHotfixState_WritesDivergedState(t *testing.T) {
+ f := &Finalizer{
+ actor: "dev",
+ deployResults: map[string]string{"app": "success", "skipped-one": "skipped"},
+ buildResults: map[string]string{"build-app": "success"},
+ }
+ cicd := &config.CICDFile{}
+
+ err := f.applyHotfixState(cicd, "uat", "newmerge", "v1.0.1", "basesha", "2026-01-01T00:00:00Z", []string{"fix1", "fix2"})
+ require.NoError(t, err)
+
+ state := cicd.State["uat"]
+ require.NotNil(t, state)
+ assert.Equal(t, "newmerge", state.SHA)
+ assert.Equal(t, "v1.0.1", state.Version)
+ assert.Equal(t, "basesha", state.BaseSHA)
+ assert.Equal(t, "env/uat", state.Ref)
+ assert.Equal(t, []string{"fix1", "fix2"}, state.Patches)
+ assert.Equal(t, "dev", state.CommittedBy)
+
+ require.NotNil(t, state.Deploys["app"], "successful deploy substate recorded")
+ assert.Equal(t, "newmerge", state.Deploys["app"].SHA)
+ assert.Nil(t, state.Deploys["skipped-one"], "skipped deploy must not be recorded")
+ require.NotNil(t, state.Builds["build-app"], "successful build substate recorded")
+}
diff --git a/internal/orchestrate/orchestrate_more_test.go b/internal/orchestrate/orchestrate_more_test.go
new file mode 100644
index 0000000..02f0163
--- /dev/null
+++ b/internal/orchestrate/orchestrate_more_test.go
@@ -0,0 +1,278 @@
+package orchestrate
+
+import (
+ "os"
+ "path/filepath"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+
+ "github.com/stablekernel/cascade/internal/config"
+)
+
+func TestNewCommand_Structure(t *testing.T) {
+ cmd := NewCommand()
+
+ assert.Equal(t, "orchestrate", cmd.Use)
+ require.NotNil(t, cmd.PersistentPreRunE)
+
+ for _, name := range []string{"config", "manifest-key", "environment", "gha-output"} {
+ assert.NotNilf(t, cmd.PersistentFlags().Lookup(name), "expected persistent flag %q", name)
+ }
+
+ var haveSetup, haveFinalize bool
+ for _, sub := range cmd.Commands() {
+ switch sub.Use {
+ case "setup":
+ haveSetup = true
+ case "finalize":
+ haveFinalize = true
+ }
+ }
+ assert.True(t, haveSetup, "expected a setup subcommand")
+ assert.True(t, haveFinalize, "expected a finalize subcommand")
+}
+
+func TestNewCommand_PersistentPreRunE_AutoDetectsConfig(t *testing.T) {
+ prev := configPath
+ t.Cleanup(func() { configPath = prev })
+
+ configPath = ""
+ cmd := NewCommand()
+ require.NoError(t, cmd.PersistentPreRunE(cmd, nil))
+ assert.NotEmpty(t, configPath)
+}
+
+func TestNewSetupCommand_Structure(t *testing.T) {
+ cmd := newSetupCommand()
+ assert.Equal(t, "setup", cmd.Use)
+ require.NotNil(t, cmd.RunE)
+ assert.NotNil(t, cmd.Flags().Lookup("sha"))
+}
+
+func TestNewFinalizeCommand_Structure(t *testing.T) {
+ cmd := newFinalizeCommand()
+ assert.Equal(t, "finalize", cmd.Use)
+ require.NotNil(t, cmd.RunE)
+ for _, name := range []string{"sha", "version", "deploy-results", "build-results"} {
+ assert.NotNilf(t, cmd.Flags().Lookup(name), "expected flag %q", name)
+ }
+}
+
+func TestRunSetup_InitError(t *testing.T) {
+ prevCfg, prevEnv := configPath, environment
+ t.Cleanup(func() { configPath, environment = prevCfg, prevEnv })
+
+ configPath = "/nonexistent/path/manifest.yaml"
+ environment = "dev"
+
+ err := runSetup(newSetupCommand(), nil)
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "initializing orchestrator")
+}
+
+func TestRunFinalize_InitError(t *testing.T) {
+ prevCfg, prevEnv := configPath, environment
+ t.Cleanup(func() { configPath, environment = prevCfg, prevEnv })
+
+ configPath = "/nonexistent/path/manifest.yaml"
+ environment = "dev"
+
+ err := runFinalize("v1.0.0", "", "")
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "initializing orchestrator")
+}
+
+func TestNewOrchestrator_NoEnvUsesDefaultStateKey(t *testing.T) {
+ tmpDir := t.TempDir()
+ configFile := filepath.Join(tmpDir, ".github", "manifest.yaml")
+ require.NoError(t, os.MkdirAll(filepath.Dir(configFile), 0o755))
+
+ manifest := `ci:
+ config:
+ trunk_branch: main
+ environments: []
+`
+ require.NoError(t, os.WriteFile(configFile, []byte(manifest), 0o600))
+
+ orch, err := NewOrchestrator(configFile, "ci", "")
+ require.NoError(t, err)
+ assert.Equal(t, DefaultStateKey, orch.environment)
+}
+
+func TestNewOrchestrator_MissingConfigSection(t *testing.T) {
+ tmpDir := t.TempDir()
+ configFile := filepath.Join(tmpDir, "manifest.yaml")
+ // A manifest with the ci key but no config section.
+ require.NoError(t, os.WriteFile(configFile, []byte("ci:\n state: {}\n"), 0o600))
+
+ _, err := NewOrchestrator(configFile, "ci", "dev")
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "config section not found")
+}
+
+func TestCalculateBaseSHAs_Priorities(t *testing.T) {
+ repoDir, head := initRepo(t)
+ defaultBase := runGit(t, repoDir, "rev-parse", "HEAD~1")
+
+ cfg := &config.TrunkConfig{
+ Builds: []config.BuildConfig{
+ {Name: "app"}, // own build state (priority 1)
+ {Name: "lib"}, // dependent deploy state (priority 2)
+ {Name: "tool"}, // env-level SHA (priority 3)
+ },
+ Deploys: []config.DeployConfig{
+ {Name: "infra"},
+ {Name: "web", DependsOn: []string{"lib"}},
+ },
+ }
+ o := &Orchestrator{baseDir: repoDir, cicdFile: &config.CICDFile{Config: cfg}}
+
+ // nil envState: every base falls back to defaultBase (HEAD~1).
+ base := o.calculateBaseSHAs(nil)
+ assert.Equal(t, defaultBase, base["build_app"])
+ assert.Equal(t, defaultBase, base["build_lib"])
+ assert.Equal(t, defaultBase, base["build_tool"])
+ assert.Equal(t, defaultBase, base["deploy_infra"])
+ assert.Equal(t, defaultBase, base["deploy_web"])
+
+ // Populated envState exercises the full priority ladder.
+ env := &config.EnvState{
+ SHA: "envsha",
+ Builds: map[string]*config.BuildState{"app": {SHA: "appbuildsha"}},
+ Deploys: map[string]*config.DeployState{
+ "web": {SHA: "webdeploysha"},
+ "infra": {SHA: "infradeploysha"},
+ },
+ }
+ base = o.calculateBaseSHAs(env)
+ assert.Equal(t, "appbuildsha", base["build_app"]) // 1. own build state
+ assert.Equal(t, "webdeploysha", base["build_lib"]) // 2. dependent deploy state
+ assert.Equal(t, "envsha", base["build_tool"]) // 3. env-level SHA
+ assert.Equal(t, "infradeploysha", base["deploy_infra"])
+ assert.Equal(t, "webdeploysha", base["deploy_web"])
+
+ _ = head
+}
+
+func TestDetectChanges(t *testing.T) {
+ repoDir, head := initRepo(t)
+ base := runGit(t, repoDir, "rev-parse", "HEAD~1")
+ o := &Orchestrator{baseDir: repoDir}
+
+ // No base SHA: assume changes.
+ assert.True(t, o.detectChanges("", head, []string{"src/**"}))
+ // No triggers: assume changes.
+ assert.True(t, o.detectChanges(base, head, nil))
+ // The HEAD commit added src/app.go, which matches src/**.
+ assert.True(t, o.detectChanges(base, head, []string{"src/**"}))
+ // No trigger matches the changed path.
+ assert.False(t, o.detectChanges(base, head, []string{"docs/**"}))
+ // A bad base SHA makes the diff fail, which conservatively assumes changes.
+ assert.True(t, o.detectChanges("deadbeefdeadbeefdeadbeefdeadbeefdeadbeef", head, []string{"src/**"}))
+}
+
+func TestCalculateVersion_EnvironmentNotFound(t *testing.T) {
+ o := &Orchestrator{
+ environment: "ghost",
+ cicdFile: &config.CICDFile{
+ Config: &config.TrunkConfig{Environments: []string{"dev", "prod"}},
+ },
+ }
+
+ _, err := o.calculateVersion()
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "not found")
+}
+
+func TestCalculateVersion_MultiEnv(t *testing.T) {
+ repoDir, head := initRepo(t)
+
+ orig, err := os.Getwd()
+ require.NoError(t, err)
+ require.NoError(t, os.Chdir(repoDir))
+ t.Cleanup(func() { require.NoError(t, os.Chdir(orig)) })
+
+ o := &Orchestrator{
+ environment: "dev",
+ baseDir: repoDir,
+ cicdFile: &config.CICDFile{
+ Config: &config.TrunkConfig{Environments: []string{"dev", "prod"}},
+ State: map[string]*config.EnvState{
+ "dev": {Version: "v1.0.0-rc.0"},
+ "prod": {Version: "v0.9.0", SHA: head},
+ },
+ },
+ }
+
+ v, err := o.calculateVersion()
+ require.NoError(t, err)
+ assert.NotEmpty(t, v)
+}
+
+func TestWriteConfig_ReadError(t *testing.T) {
+ o := &Orchestrator{
+ configPath: "/nonexistent/path/manifest.yaml",
+ cicdFile: &config.CICDFile{Config: &config.TrunkConfig{}},
+ }
+
+ err := o.writeConfig()
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "failed to read config")
+}
+
+func TestWriteConfig_CustomManifestKey(t *testing.T) {
+ tmpDir := t.TempDir()
+ p := filepath.Join(tmpDir, "manifest.yaml")
+ seeded := `pipeline:
+ config:
+ trunk_branch: main
+ state:
+ dev:
+ sha: oldsha
+`
+ require.NoError(t, os.WriteFile(p, []byte(seeded), 0o600))
+
+ o := &Orchestrator{
+ configPath: p,
+ cicdFile: &config.CICDFile{
+ Config: &config.TrunkConfig{ManifestKey: "pipeline"},
+ State: map[string]*config.EnvState{"dev": {SHA: "newsha"}},
+ },
+ }
+
+ require.NoError(t, o.writeConfig())
+
+ data, err := os.ReadFile(p)
+ require.NoError(t, err)
+ assert.Contains(t, string(data), "pipeline:")
+ assert.Contains(t, string(data), "newsha")
+ assert.NotContains(t, string(data), "oldsha")
+}
+
+func TestCommitAndPush_NoChanges(t *testing.T) {
+ repoDir, _ := initRepo(t)
+ manifestPath := writeManifest(t, repoDir, "abc123")
+ // Commit the manifest so the working tree is clean for the configured path.
+ runGit(t, repoDir, "add", ".github/manifest.yaml")
+ runGit(t, repoDir, "commit", "-m", "chore: add manifest")
+
+ o := &Orchestrator{
+ baseDir: repoDir,
+ configPath: manifestPath,
+ environment: "prerelease",
+ cicdFile: &config.CICDFile{},
+ }
+
+ // With nothing to commit, commitAndPush returns early without needing a remote.
+ require.NoError(t, o.commitAndPush("v0.1.0"))
+}
+
+func TestGitOutput_Error(t *testing.T) {
+ repoDir, _ := initRepo(t)
+ o := &Orchestrator{baseDir: repoDir}
+
+ _, err := o.gitOutput("rev-parse", "--verify", "does-not-exist")
+ require.Error(t, err)
+}
diff --git a/internal/promote/finalize_helpers_test.go b/internal/promote/finalize_helpers_test.go
new file mode 100644
index 0000000..a8c046d
--- /dev/null
+++ b/internal/promote/finalize_helpers_test.go
@@ -0,0 +1,154 @@
+package promote
+
+import (
+ "testing"
+
+ "github.com/stablekernel/cascade/internal/config"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+// TestGetEnv covers both the present-value and default-fallback branches.
+func TestGetEnv(t *testing.T) {
+ t.Run("returns value when set", func(t *testing.T) {
+ t.Setenv("CASCADE_GETENV_PROBE", "actual")
+ assert.Equal(t, "actual", getEnv("CASCADE_GETENV_PROBE", "fallback"))
+ })
+
+ t.Run("returns default when empty", func(t *testing.T) {
+ t.Setenv("CASCADE_GETENV_PROBE", "")
+ assert.Equal(t, "fallback", getEnv("CASCADE_GETENV_PROBE", "fallback"))
+ })
+
+ t.Run("returns default when unset", func(t *testing.T) {
+ assert.Equal(t, "fallback", getEnv("CASCADE_GETENV_DEFINITELY_UNSET", "fallback"))
+ })
+}
+
+// TestGitIdentity verifies the manifest git config maps to the statewrite
+// identity, and that a nil config yields the empty (bot-default) identity.
+func TestGitIdentity(t *testing.T) {
+ t.Run("nil config yields empty identity", func(t *testing.T) {
+ id := gitIdentity(nil)
+ assert.Empty(t, id.Name)
+ assert.Empty(t, id.Email)
+ })
+
+ t.Run("config without git block uses bot defaults", func(t *testing.T) {
+ id := gitIdentity(&config.TrunkConfig{})
+ assert.Equal(t, "github-actions[bot]", id.Name)
+ assert.Equal(t, "github-actions[bot]@users.noreply.github.com", id.Email)
+ })
+
+ t.Run("config with git block uses configured values", func(t *testing.T) {
+ cfg := &config.TrunkConfig{Git: &config.GitConfig{
+ UserName: "Release Bot",
+ UserEmail: "release@example.com",
+ }}
+ id := gitIdentity(cfg)
+ assert.Equal(t, "Release Bot", id.Name)
+ assert.Equal(t, "release@example.com", id.Email)
+ })
+}
+
+// newExternalFinalizer builds a Finalizer whose manifest declares one external
+// repo with a single external deploy, plus a source env that recorded a SHA and
+// version for that deploy. It is the fixture for the external-deploy helpers.
+func newExternalFinalizer(deployName string) *Finalizer {
+ return &Finalizer{
+ targetEnv: "uat",
+ actor: "deployer",
+ cicdFile: &config.CICDFile{
+ Config: &config.TrunkConfig{
+ External: []config.ExternalRepoConfig{{
+ Repo: "org/satellite",
+ Deploys: []config.ExternalDeployConfig{{
+ Name: deployName,
+ }},
+ }},
+ },
+ State: map[string]*config.EnvState{
+ "test": {
+ External: map[string]*config.ExternalDeployState{
+ deployName: {
+ Repo: "org/satellite",
+ SHA: "ext-sha-123",
+ Version: "v9.9.9",
+ },
+ },
+ },
+ },
+ },
+ promotionResult: &PromotionResult{
+ Promotions: []EnvPromotion{{
+ Environment: "uat",
+ SourceEnv: "test",
+ }},
+ },
+ }
+}
+
+// TestIsExternalDeploy distinguishes external deploys from unknown names.
+func TestIsExternalDeploy(t *testing.T) {
+ f := newExternalFinalizer("cdk")
+ assert.True(t, f.isExternalDeploy("cdk"), "declared external deploy must be recognized")
+ assert.False(t, f.isExternalDeploy("not-external"), "unknown name is not external")
+}
+
+// TestGetExternalDeployRepo returns the owning repo for a known deploy and empty
+// for an unknown one.
+func TestGetExternalDeployRepo(t *testing.T) {
+ f := newExternalFinalizer("cdk")
+ assert.Equal(t, "org/satellite", f.getExternalDeployRepo("cdk"))
+ assert.Equal(t, "", f.getExternalDeployRepo("missing"))
+}
+
+// TestGetExternalDeploySHA reads the SHA recorded for the deploy in the source
+// environment state and returns empty when there is no promotion context.
+func TestGetExternalDeploySHA(t *testing.T) {
+ f := newExternalFinalizer("cdk")
+ assert.Equal(t, "ext-sha-123", f.getExternalDeploySHA("cdk"))
+ assert.Equal(t, "", f.getExternalDeploySHA("missing"), "unknown deploy has no SHA")
+
+ f.promotionResult = nil
+ assert.Equal(t, "", f.getExternalDeploySHA("cdk"), "no promotion context yields empty SHA")
+}
+
+// TestGetExternalDeployVersion mirrors the SHA lookup for the version field.
+func TestGetExternalDeployVersion(t *testing.T) {
+ f := newExternalFinalizer("cdk")
+ assert.Equal(t, "v9.9.9", f.getExternalDeployVersion("cdk"))
+ assert.Equal(t, "", f.getExternalDeployVersion("missing"))
+
+ f.promotionResult = &PromotionResult{}
+ assert.Equal(t, "", f.getExternalDeployVersion("cdk"), "empty promotions yields empty version")
+}
+
+// TestUpdateExternalDeployState writes the source SHA, version, repo, and actor
+// onto the target env's external state.
+func TestUpdateExternalDeployState(t *testing.T) {
+ f := newExternalFinalizer("cdk")
+ f.updateExternalDeployState("cdk", "2026-01-02T03:04:05Z")
+
+ target := f.cicdFile.State["uat"]
+ require.NotNil(t, target, "target env state must be created")
+ es := target.External["cdk"]
+ require.NotNil(t, es, "external deploy state must be recorded")
+ assert.Equal(t, "org/satellite", es.Repo)
+ assert.Equal(t, "ext-sha-123", es.SHA)
+ assert.Equal(t, "v9.9.9", es.Version)
+ assert.Equal(t, "2026-01-02T03:04:05Z", es.DeployedAt)
+ assert.Equal(t, "deployer", es.DeployedBy)
+}
+
+// TestUpdateExternalDeployState_NoSourceSHA is a no-op when the source env has
+// not recorded a SHA for the deploy.
+func TestUpdateExternalDeployState_NoSourceSHA(t *testing.T) {
+ f := newExternalFinalizer("cdk")
+ // Drop the source SHA so the helper takes its early-return branch.
+ f.cicdFile.State["test"].External["cdk"].SHA = ""
+
+ f.updateExternalDeployState("cdk", "2026-01-02T03:04:05Z")
+
+ assert.Nil(t, f.cicdFile.State["uat"], "no SHA means no target state is created")
+}
diff --git a/internal/promote/preflight_glob_test.go b/internal/promote/preflight_glob_test.go
new file mode 100644
index 0000000..0d9cc1c
--- /dev/null
+++ b/internal/promote/preflight_glob_test.go
@@ -0,0 +1,84 @@
+package promote
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+)
+
+// TestMatchGlob covers the single-segment and doublestar dispatch of matchGlob.
+func TestMatchGlob(t *testing.T) {
+ cases := []struct {
+ name string
+ pattern string
+ path string
+ want bool
+ }{
+ {"exact match", "go.mod", "go.mod", true},
+ {"exact non-match", "go.mod", "go.sum", false},
+ {"single star matches within a segment", "src/*.go", "src/main.go", true},
+ {"single star does not span directories", "src/*.go", "src/sub/main.go", false},
+ {"single star same dir", "*.go", "main.go", true},
+ {"single star does not cross slash", "*.go", "src/main.go", false},
+ {"doublestar prefix matches nested", "src/**", "src/a/b/c.go", true},
+ {"doublestar prefix matches direct child", "src/**", "src/main.go", true},
+ {"doublestar non-match wrong root", "src/**", "lib/main.go", false},
+ {"doublestar middle segment", "a/**/d.go", "a/b/c/d.go", true},
+ {"doublestar middle non-match", "a/**/d.go", "a/b/c/e.go", false},
+ }
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ assert.Equal(t, tc.want, matchGlob(tc.pattern, tc.path))
+ })
+ }
+}
+
+// TestMatchDoublestar exercises the ** handling directly, including the
+// trailing-** "matches everything" branch and exhausted-pattern checks.
+func TestMatchDoublestar(t *testing.T) {
+ cases := []struct {
+ name string
+ pattern string
+ path string
+ want bool
+ }{
+ {"trailing doublestar matches deep path", "infra/**", "infra/modules/vpc/main.tf", true},
+ {"trailing doublestar matches single", "infra/**", "infra/main.tf", true},
+ {"leading doublestar matches suffix", "**/main.go", "a/b/main.go", true},
+ {"leading doublestar matches root file", "**/main.go", "main.go", true},
+ {"leading doublestar suffix mismatch", "**/main.go", "a/b/other.go", false},
+ {"doublestar with wildcard segment", "src/**/*.go", "src/a/b.go", true},
+ {"prefix mismatch before doublestar", "x/**", "y/z", false},
+ }
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ assert.Equal(t, tc.want, matchDoublestar(tc.pattern, tc.path))
+ })
+ }
+}
+
+// TestMatchParts covers the recursive segment matcher edge cases that the
+// higher-level helpers route into: exhausted pattern, exhausted path, and the
+// zero-segment ** match.
+func TestMatchParts(t *testing.T) {
+ cases := []struct {
+ name string
+ pattern []string
+ path []string
+ want bool
+ }{
+ {"equal literal segments", []string{"a", "b"}, []string{"a", "b"}, true},
+ {"pattern longer than path", []string{"a", "b", "c"}, []string{"a", "b"}, false},
+ {"path longer than pattern without doublestar", []string{"a"}, []string{"a", "b"}, false},
+ {"doublestar absorbs zero segments", []string{"a", "**"}, []string{"a"}, true},
+ {"doublestar absorbs many segments", []string{"a", "**"}, []string{"a", "b", "c"}, true},
+ {"trailing doublestar after full match", []string{"a", "b", "**"}, []string{"a", "b"}, true},
+ {"both empty", []string{}, []string{}, true},
+ {"empty pattern non-empty path", []string{}, []string{"a"}, false},
+ }
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ assert.Equal(t, tc.want, matchParts(tc.pattern, tc.path))
+ })
+ }
+}
diff --git a/internal/release/coverage_test.go b/internal/release/coverage_test.go
new file mode 100644
index 0000000..0407ad7
--- /dev/null
+++ b/internal/release/coverage_test.go
@@ -0,0 +1,244 @@
+package release
+
+import (
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestNewManager_DefaultBaseURL(t *testing.T) {
+ t.Setenv("GITHUB_API_URL", "")
+ m := NewManager("owner/repo", "tok")
+ assert.Equal(t, "https://api.github.com", m.baseURL)
+ assert.Equal(t, "owner/repo", m.repo)
+ assert.Equal(t, "tok", m.token)
+ assert.NotNil(t, m.client)
+ assert.NotNil(t, m.sleepFn)
+}
+
+func TestNewManager_RespectsAPIURLEnv(t *testing.T) {
+ t.Setenv("GITHUB_API_URL", "https://ghe.example.com/api/v3/")
+ m := NewManager("owner/repo", "tok")
+ // Trailing slash is trimmed.
+ assert.Equal(t, "https://ghe.example.com/api/v3", m.baseURL)
+}
+
+func TestNewManagerWithURL_TrimsTrailingSlash(t *testing.T) {
+ m := NewManagerWithURL("owner/repo", "tok", "https://api.example.com/")
+ assert.Equal(t, "https://api.example.com", m.baseURL)
+ assert.Equal(t, "owner/repo", m.repo)
+ assert.Equal(t, "tok", m.token)
+ assert.NotNil(t, m.client)
+ assert.NotNil(t, m.sleepFn)
+}
+
+func TestIsGitHubHost(t *testing.T) {
+ assert.True(t, isGitHubHost("https://api.github.com"))
+ assert.True(t, isGitHubHost("https://ghe.github.example.com"))
+ assert.False(t, isGitHubHost("http://localhost:3000"))
+ assert.False(t, isGitHubHost("http://gitea.local"))
+}
+
+func TestSplitVersionPrefix(t *testing.T) {
+ tests := []struct {
+ tag string
+ wantPrefix string
+ wantErr bool
+ }{
+ {"v1.2.3", "v", false},
+ {"1.0.0", "", false},
+ {"rel-1.0.0", "rel-", false},
+ {"release/2.0.0", "release/", false},
+ {"no-core-here", "", true},
+ {"v1.2", "", true},
+ }
+ for _, tt := range tests {
+ t.Run(tt.tag, func(t *testing.T) {
+ prefix, v, err := splitVersionPrefix(tt.tag)
+ if tt.wantErr {
+ require.Error(t, err)
+ assert.Nil(t, v)
+ return
+ }
+ require.NoError(t, err)
+ assert.Equal(t, tt.wantPrefix, prefix)
+ require.NotNil(t, v)
+ })
+ }
+}
+
+func TestNewRequest_InvalidMethodErrors(t *testing.T) {
+ m := NewManagerWithURL("owner/repo", "tok", "https://api.github.com")
+ _, err := m.newRequest("BAD METHOD", "/releases", nil)
+ require.Error(t, err)
+}
+
+func TestNewRequest_SetsHeaders(t *testing.T) {
+ m := NewManagerWithURL("owner/repo", "tok", "https://api.github.com")
+ req, err := m.newRequest("POST", "/releases", map[string]interface{}{"a": 1})
+ require.NoError(t, err)
+ assert.Equal(t, "application/vnd.github+json", req.Header.Get("Accept"))
+ assert.Equal(t, "Bearer tok", req.Header.Get("Authorization"))
+ assert.Equal(t, "2022-11-28", req.Header.Get("X-GitHub-Api-Version"))
+ assert.Equal(t, "application/json", req.Header.Get("Content-Type"))
+ assert.Equal(t, "https://api.github.com/repos/owner/repo/releases", req.URL.String())
+}
+
+func TestListTags_ParsesRefs(t *testing.T) {
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ assert.Equal(t, "GET", r.Method)
+ assert.Contains(t, r.URL.Path, "/git/refs/tags")
+ _ = json.NewEncoder(w).Encode([]map[string]string{
+ {"ref": "refs/tags/v1.0.0"},
+ {"ref": "refs/tags/v1.1.0-rc.1"},
+ })
+ }))
+ defer server.Close()
+
+ m := &Manager{client: server.Client(), baseURL: server.URL, token: "t", repo: "owner/repo"}
+ tags, err := m.listTags()
+ require.NoError(t, err)
+ assert.Equal(t, []string{"v1.0.0", "v1.1.0-rc.1"}, tags)
+}
+
+func TestListTags_NotFoundReturnsEmpty(t *testing.T) {
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.WriteHeader(http.StatusNotFound)
+ }))
+ defer server.Close()
+
+ m := &Manager{client: server.Client(), baseURL: server.URL, token: "t", repo: "owner/repo"}
+ tags, err := m.listTags()
+ require.NoError(t, err)
+ assert.Empty(t, tags)
+}
+
+func TestListTags_ServerErrorReturnsError(t *testing.T) {
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.WriteHeader(http.StatusInternalServerError)
+ _, _ = w.Write([]byte("boom"))
+ }))
+ defer server.Close()
+
+ m := &Manager{client: server.Client(), baseURL: server.URL, token: "t", repo: "owner/repo"}
+ _, err := m.listTags()
+ require.Error(t, err)
+}
+
+func TestDeleteGitTag_AcceptsNoContentAndNotFound(t *testing.T) {
+ for _, status := range []int{http.StatusNoContent, http.StatusNotFound} {
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ assert.Equal(t, "DELETE", r.Method)
+ w.WriteHeader(status)
+ }))
+ m := &Manager{client: server.Client(), baseURL: server.URL, token: "t", repo: "owner/repo"}
+ err := m.deleteGitTag("v1.0.0-rc.1")
+ server.Close()
+ require.NoError(t, err, "status %d should be accepted", status)
+ }
+}
+
+func TestDeleteGitTag_ServerErrorReturnsError(t *testing.T) {
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.WriteHeader(http.StatusInternalServerError)
+ _, _ = w.Write([]byte("nope"))
+ }))
+ defer server.Close()
+
+ m := &Manager{client: server.Client(), baseURL: server.URL, token: "t", repo: "owner/repo"}
+ err := m.deleteGitTag("v1.0.0-rc.1")
+ require.Error(t, err)
+}
+
+func TestCreateGitTag_SkipsNonGitHubHost(t *testing.T) {
+ m := &Manager{client: http.DefaultClient, baseURL: "http://gitea.local", token: "t", repo: "owner/repo"}
+ // No server is contacted on a non-GitHub host.
+ require.NoError(t, m.createGitTag("v1.0.0", "abc123"))
+}
+
+func TestCreateGitTag_AcceptsCreatedAndExists(t *testing.T) {
+ for _, status := range []int{http.StatusCreated, http.StatusUnprocessableEntity} {
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ assert.Equal(t, "POST", r.Method)
+ assert.Contains(t, r.URL.Path, "/git/refs")
+ w.WriteHeader(status)
+ }))
+ m := &Manager{client: server.Client(), baseURL: server.URL + "/github", token: "t", repo: "owner/repo"}
+ err := m.createGitTag("v1.0.0", "abc123")
+ server.Close()
+ require.NoError(t, err, "status %d should be accepted", status)
+ }
+}
+
+func TestCreateGitTag_ServerErrorReturnsError(t *testing.T) {
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.WriteHeader(http.StatusInternalServerError)
+ _, _ = w.Write([]byte("bad ref"))
+ }))
+ defer server.Close()
+
+ m := &Manager{client: server.Client(), baseURL: server.URL + "/github", token: "t", repo: "owner/repo"}
+ err := m.createGitTag("v1.0.0", "abc123")
+ require.Error(t, err)
+}
+
+func TestApiRequest_NonSuccessReturnsError(t *testing.T) {
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.WriteHeader(http.StatusForbidden)
+ _, _ = w.Write([]byte("forbidden"))
+ }))
+ defer server.Close()
+
+ m := &Manager{client: server.Client(), baseURL: server.URL, token: "t", repo: "owner/repo"}
+ _, err := m.apiRequest("POST", "/releases", map[string]interface{}{"a": 1})
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "403")
+}
+
+func TestApiRequest_InvalidJSONReturnsError(t *testing.T) {
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.WriteHeader(http.StatusOK)
+ _, _ = w.Write([]byte("not json"))
+ }))
+ defer server.Close()
+
+ m := &Manager{client: server.Client(), baseURL: server.URL, token: "t", repo: "owner/repo"}
+ _, err := m.apiRequest("GET", "/releases/1", nil)
+ require.Error(t, err)
+}
+
+func TestFindRelease_DirectTagHit(t *testing.T) {
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ assert.Contains(t, r.URL.Path, "/releases/tags/v1.0.0")
+ _ = json.NewEncoder(w).Encode(GitHubRelease{ID: 42, TagName: "v1.0.0"})
+ }))
+ defer server.Close()
+
+ m := &Manager{client: server.Client(), baseURL: server.URL, token: "t", repo: "owner/repo"}
+ rel, err := m.findRelease("v1.0.0", "")
+ require.NoError(t, err)
+ require.NotNil(t, rel)
+ assert.Equal(t, int64(42), rel.ID)
+}
+
+func TestFindRelease_DirectErrorStatus(t *testing.T) {
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.WriteHeader(http.StatusInternalServerError)
+ _, _ = w.Write([]byte("server error"))
+ }))
+ defer server.Close()
+
+ m := &Manager{client: server.Client(), baseURL: server.URL, token: "t", repo: "owner/repo"}
+ _, err := m.findRelease("v1.0.0", "")
+ require.Error(t, err)
+}
+
+func TestCleanupRCTags_InvalidPublishedVersionErrors(t *testing.T) {
+ m := NewManagerWithURL("owner/repo", "t", "https://api.github.com")
+ err := m.cleanupRCTags("not-a-version")
+ require.Error(t, err)
+}
diff --git a/internal/reset/reset_more_test.go b/internal/reset/reset_more_test.go
new file mode 100644
index 0000000..32a04e4
--- /dev/null
+++ b/internal/reset/reset_more_test.go
@@ -0,0 +1,227 @@
+package reset
+
+import (
+ "os"
+ "path/filepath"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+
+ "github.com/stablekernel/cascade/internal/config"
+)
+
+// newResetRepo creates a real git repo with a deterministic identity, an initial
+// commit (so HEAD and the current branch resolve), and an origin remote pointing
+// at a GitHub-style SSH URL so getRepoInfo can parse owner/name without any
+// network access.
+func newResetRepo(t *testing.T) string {
+ t.Helper()
+ dir := t.TempDir()
+ gitInDir(t, "", "init", "-b", "main", dir)
+ configureGitIdentity(t, dir)
+ require.NoError(t, os.WriteFile(filepath.Join(dir, "README.md"), []byte("# fixture\n"), 0o600))
+ gitInDir(t, dir, "add", "README.md")
+ gitInDir(t, dir, "commit", "-m", "chore: init")
+ gitInDir(t, dir, "remote", "add", "origin", "git@github.com:test/repo.git")
+ return dir
+}
+
+func TestNewCommand_Structure(t *testing.T) {
+ cmd := NewCommand()
+
+ assert.Equal(t, "reset", cmd.Use)
+ require.NotNil(t, cmd.RunE)
+ require.NotNil(t, cmd.PersistentPreRunE)
+
+ for _, name := range []string{"state", "dry-run", "push", "repo", "config", "manifest-key"} {
+ assert.NotNilf(t, cmd.Flags().Lookup(name), "expected flag %q to be registered", name)
+ }
+
+ // The manifest-key flag defaults to the package default.
+ mk := cmd.Flags().Lookup("manifest-key")
+ require.NotNil(t, mk)
+ assert.Equal(t, config.DefaultManifestKey, mk.DefValue)
+}
+
+func TestNewCommand_PersistentPreRunE_AutoDetectsConfig(t *testing.T) {
+ cmd := NewCommand()
+ // With no --config provided, the pre-run hook resolves a default config path
+ // rather than leaving it empty.
+ require.NoError(t, cmd.PersistentPreRunE(cmd, nil))
+ cfg := cmd.Flags().Lookup("config")
+ require.NotNil(t, cfg)
+ assert.NotEmpty(t, cfg.Value.String())
+}
+
+func TestRunReset_InitError(t *testing.T) {
+ // A non-git directory makes New fail, surfacing through runReset.
+ dir := t.TempDir()
+ err := runReset(Options{RepoPath: dir})
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "failed to initialize resetter")
+}
+
+func TestRunReset_DryRunInitError(t *testing.T) {
+ // Exercises the dry-run banner branch while still failing at initialization.
+ dir := t.TempDir()
+ err := runReset(Options{RepoPath: dir, DryRun: true})
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "failed to initialize resetter")
+}
+
+func TestNew_Success(t *testing.T) {
+ dir := newResetRepo(t)
+
+ r, err := New(Options{RepoPath: dir})
+ require.NoError(t, err)
+ assert.Equal(t, dir, r.repoPath)
+ assert.Equal(t, "test", r.repoOwner)
+ assert.Equal(t, "repo", r.repoName)
+ // ManifestKey defaults when unset.
+ assert.Equal(t, config.DefaultManifestKey, r.manifestKey)
+ // Without ResetState the manifest is not parsed.
+ assert.Nil(t, r.cicdFile)
+}
+
+func TestNew_ResetStateLoadsManifest(t *testing.T) {
+ dir := newResetRepo(t)
+ manifest := `ci:
+ config:
+ trunk_branch: main
+ environments:
+ - dev
+ state:
+ dev:
+ sha: abc123
+ version: v1.0.0
+`
+ require.NoError(t, os.MkdirAll(filepath.Join(dir, ".github"), 0o755))
+ require.NoError(t, os.WriteFile(filepath.Join(dir, ".github", "manifest.yaml"), []byte(manifest), 0o600))
+
+ r, err := New(Options{RepoPath: dir, ResetState: true})
+ require.NoError(t, err)
+ require.NotNil(t, r.cicdFile)
+ require.NotNil(t, r.cicdFile.State["dev"])
+ assert.Equal(t, "abc123", r.cicdFile.State["dev"].SHA)
+}
+
+func TestNew_ResetStateMissingManifest(t *testing.T) {
+ dir := newResetRepo(t)
+
+ // No manifest on disk: parsing for ResetState must surface an error.
+ _, err := New(Options{RepoPath: dir, ResetState: true})
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "failed to parse manifest")
+}
+
+func TestNew_CustomConfigAndManifestKey(t *testing.T) {
+ dir := newResetRepo(t)
+ cfgPath := filepath.Join(dir, "pipeline.yaml")
+ manifest := `pipeline:
+ config:
+ trunk_branch: main
+ environments:
+ - dev
+ state:
+ dev:
+ sha: zzz999
+`
+ require.NoError(t, os.WriteFile(cfgPath, []byte(manifest), 0o600))
+
+ r, err := New(Options{RepoPath: dir, ConfigPath: cfgPath, ManifestKey: "pipeline", ResetState: true})
+ require.NoError(t, err)
+ assert.Equal(t, cfgPath, r.configPath)
+ assert.Equal(t, "pipeline", r.manifestKey)
+ require.NotNil(t, r.cicdFile)
+ require.NotNil(t, r.cicdFile.State["dev"])
+ assert.Equal(t, "zzz999", r.cicdFile.State["dev"].SHA)
+}
+
+func TestGetRepoInfo_Success(t *testing.T) {
+ dir := newResetRepo(t)
+ owner, name, err := getRepoInfo(dir)
+ require.NoError(t, err)
+ assert.Equal(t, "test", owner)
+ assert.Equal(t, "repo", name)
+}
+
+func TestGetRepoInfo_NoRemote(t *testing.T) {
+ dir := t.TempDir()
+ gitInDir(t, "", "init", "-b", "main", dir)
+ configureGitIdentity(t, dir)
+
+ _, _, err := getRepoInfo(dir)
+ require.Error(t, err)
+}
+
+func TestCurrentBranch(t *testing.T) {
+ dir := newResetRepo(t)
+ r := &Resetter{repoPath: dir}
+
+ branch, err := r.currentBranch()
+ require.NoError(t, err)
+ assert.Equal(t, "main", branch)
+}
+
+func TestGitOutput_Error(t *testing.T) {
+ dir := newResetRepo(t)
+ r := &Resetter{repoPath: dir}
+
+ // rev-parse of a missing ref exits non-zero, surfacing the error path.
+ _, err := r.gitOutput("rev-parse", "--verify", "does-not-exist")
+ require.Error(t, err)
+}
+
+func TestDeleteAllTags_NoTags(t *testing.T) {
+ dir := newResetRepo(t)
+ r := &Resetter{repoPath: dir, opts: Options{}}
+
+ n, err := r.deleteAllTags()
+ require.NoError(t, err)
+ assert.Equal(t, 0, n)
+}
+
+func TestDeleteAllTags_DryRun(t *testing.T) {
+ dir := newResetRepo(t)
+ gitInDir(t, dir, "tag", "v1.0.0")
+ gitInDir(t, dir, "tag", "v1.1.0")
+
+ r := &Resetter{repoPath: dir, opts: Options{DryRun: true}}
+ n, err := r.deleteAllTags()
+ require.NoError(t, err)
+ assert.Equal(t, 2, n)
+
+ // Dry-run must not delete the local tags.
+ out, err := r.gitOutput("tag", "-l")
+ require.NoError(t, err)
+ assert.Contains(t, out, "v1.0.0")
+ assert.Contains(t, out, "v1.1.0")
+}
+
+func TestDeleteAllTags_DeletesLocalAndRemote(t *testing.T) {
+ // A local bare remote keeps the remote-tag deletion fast and hermetic
+ // (no GitHub network), while still exercising the delete loops.
+ remote := t.TempDir()
+ gitInDir(t, "", "init", "--bare", "-b", "main", remote)
+
+ dir := t.TempDir()
+ gitInDir(t, "", "clone", remote, dir)
+ configureGitIdentity(t, dir)
+ require.NoError(t, os.WriteFile(filepath.Join(dir, "README.md"), []byte("# fixture\n"), 0o600))
+ gitInDir(t, dir, "add", "README.md")
+ gitInDir(t, dir, "commit", "-m", "chore: init")
+ gitInDir(t, dir, "push", "origin", "HEAD:main")
+ gitInDir(t, dir, "tag", "v1.0.0")
+ gitInDir(t, dir, "push", "origin", "v1.0.0")
+
+ r := &Resetter{repoPath: dir, opts: Options{}}
+ n, err := r.deleteAllTags()
+ require.NoError(t, err)
+ assert.Equal(t, 1, n)
+
+ // The local tag is gone.
+ out, err := r.gitOutput("tag", "-l")
+ require.NoError(t, err)
+ assert.Empty(t, out)
+}
diff --git a/internal/rollback/coverage_test.go b/internal/rollback/coverage_test.go
new file mode 100644
index 0000000..eb1638b
--- /dev/null
+++ b/internal/rollback/coverage_test.go
@@ -0,0 +1,375 @@
+package rollback
+
+import (
+ "os"
+ "os/exec"
+ "path/filepath"
+ "testing"
+
+ "github.com/stablekernel/cascade/internal/config"
+ "github.com/stablekernel/cascade/internal/statewrite"
+)
+
+// stateOnlyManifest writes a manifest that declares no config block at all, only
+// recorded state. It exercises the no-config branches (DeployNames returning nil,
+// knownEnvironment falling back to recorded state).
+func stateOnlyManifest(t *testing.T) string {
+ t.Helper()
+ dir := t.TempDir()
+ path := filepath.Join(dir, "manifest.yaml")
+ content := `ci:
+ state:
+ prod:
+ sha: prodnew7654321
+ version: v2.0.0
+ committed_at: "2026-03-01T11:00:00Z"
+ committed_by: alice
+ previous:
+ - sha: prodold1112223
+ version: v1.9.0
+ committed_at: "2026-02-15T11:00:00Z"
+ committed_by: alice
+`
+ if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
+ t.Fatalf("write manifest: %v", err)
+ }
+ return path
+}
+
+func TestConfigPath_ReturnsResolvedPath(t *testing.T) {
+ dir := t.TempDir()
+ path := writeManifest(t, dir, "prodsha9999999", "v1.9.0")
+ rb := newRollbacker(t, path, fakeHistory{})
+ if got := rb.ConfigPath(); got != path {
+ t.Errorf("ConfigPath() = %q, want %q", got, path)
+ }
+}
+
+func TestGitIdentity_DefaultsToBot(t *testing.T) {
+ dir := t.TempDir()
+ path := writeManifest(t, dir, "prodsha9999999", "v1.9.0")
+ rb := newRollbacker(t, path, fakeHistory{})
+
+ id := rb.GitIdentity()
+ if id.Name != "github-actions[bot]" {
+ t.Errorf("Name = %q, want github-actions[bot]", id.Name)
+ }
+ if id.Email != "github-actions[bot]@users.noreply.github.com" {
+ t.Errorf("Email = %q, want bot noreply", id.Email)
+ }
+}
+
+func TestGitIdentity_HonorsManifestGitConfig(t *testing.T) {
+ dir := t.TempDir()
+ path := filepath.Join(dir, "manifest.yaml")
+ content := `ci:
+ config:
+ trunk_branch: main
+ environments:
+ - prod
+ git:
+ user_name: release-bot
+ user_email: release-bot@example.com
+ state:
+ prod:
+ sha: prodsha9999999
+ version: v1.9.0
+ committed_at: "2026-02-01T11:00:00Z"
+ committed_by: alice
+`
+ if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
+ t.Fatalf("write manifest: %v", err)
+ }
+ rb := newRollbacker(t, path, fakeHistory{})
+
+ id := rb.GitIdentity()
+ if id.Name != "release-bot" {
+ t.Errorf("Name = %q, want release-bot", id.Name)
+ }
+ if id.Email != "release-bot@example.com" {
+ t.Errorf("Email = %q, want release-bot@example.com", id.Email)
+ }
+}
+
+func TestDeployNames_WithAndWithoutConfig(t *testing.T) {
+ dir := t.TempDir()
+ withConfig := writeManifest(t, dir, "prodsha9999999", "v1.9.0")
+ rb := newRollbacker(t, withConfig, fakeHistory{})
+ names := rb.DeployNames()
+ if len(names) != 1 || names[0] != "services" {
+ t.Errorf("DeployNames() = %v, want [services]", names)
+ }
+
+ noConfig := stateOnlyManifest(t)
+ rb2 := newRollbacker(t, noConfig, fakeHistory{})
+ if got := rb2.DeployNames(); got != nil {
+ t.Errorf("DeployNames() with no config = %v, want nil", got)
+ }
+}
+
+func TestKnownEnvironment_FallsBackToRecordedState(t *testing.T) {
+ path := stateOnlyManifest(t)
+ rb := newRollbacker(t, path, fakeHistory{})
+ // prod has recorded state but no config.environments declaration.
+ if !rb.knownEnvironment("prod") {
+ t.Error("prod should be known via recorded state")
+ }
+ if rb.knownEnvironment("ghost") {
+ t.Error("ghost should not be known")
+ }
+}
+
+func TestOrDash(t *testing.T) {
+ if got := orDash(""); got != "-" {
+ t.Errorf("orDash(\"\") = %q, want -", got)
+ }
+ if got := orDash("v1.0.0"); got != "v1.0.0" {
+ t.Errorf("orDash(v1.0.0) = %q, want v1.0.0", got)
+ }
+}
+
+func TestTruncate(t *testing.T) {
+ cases := []struct{ in, want string }{
+ {"abcdef1234567", "abcdef1"},
+ {"abc", "abc"},
+ {"", "-"},
+ {"1234567", "1234567"},
+ }
+ for _, c := range cases {
+ if got := truncate(c.in); got != c.want {
+ t.Errorf("truncate(%q) = %q, want %q", c.in, got, c.want)
+ }
+ }
+}
+
+func TestGetEnv(t *testing.T) {
+ t.Setenv("ROLLBACK_TEST_KEY", "value")
+ if got := getEnv("ROLLBACK_TEST_KEY", "fallback"); got != "value" {
+ t.Errorf("getEnv set = %q, want value", got)
+ }
+ if got := getEnv("ROLLBACK_TEST_UNSET_KEY", "fallback"); got != "fallback" {
+ t.Errorf("getEnv unset = %q, want fallback", got)
+ }
+}
+
+func TestMatchHelpers_NilInputs(t *testing.T) {
+ if got := matchEnv(nil, "x", "state"); got != nil {
+ t.Errorf("matchEnv(nil) = %+v, want nil", got)
+ }
+ if got := matchDeploy(nil, "x", "svc", "state"); got != nil {
+ t.Errorf("matchDeploy(nil) = %+v, want nil", got)
+ }
+}
+
+func TestResolveDefaultTarget_GitHistoryFallback(t *testing.T) {
+ dir := t.TempDir()
+ // Live prod is at v2.0.0 with no ring; the distinct prior lives in history.
+ path := writeManifest(t, dir, "newprodsha1234", "v2.0.0")
+ hist := fakeHistory{states: map[string][]*config.EnvState{
+ "prod": {{SHA: "oldprodsha5678", Version: "v1.8.0"}},
+ }}
+ rb := newRollbacker(t, path, hist)
+
+ plan, err := rb.Plan("prod", "", "")
+ if err != nil {
+ t.Fatalf("Plan: %v", err)
+ }
+ if plan.Target.SHA != "oldprodsha5678" {
+ t.Errorf("target sha = %q, want oldprodsha5678", plan.Target.SHA)
+ }
+ if plan.Target.Source != "git-history" {
+ t.Errorf("source = %q, want git-history", plan.Target.Source)
+ }
+}
+
+func TestResolveDefaultTarget_DeployableScopedFromHistory(t *testing.T) {
+ dir := t.TempDir()
+ path := writeManifest(t, dir, "newprodsha1234", "v2.0.0")
+ hist := fakeHistory{states: map[string][]*config.EnvState{
+ "prod": {{SHA: "oldprodsha5678", Version: "v1.8.0",
+ Deploys: map[string]*config.DeployState{
+ "services": {SHA: "svcsha111", Version: "v1.8.0-svc"},
+ }}},
+ }}
+ rb := newRollbacker(t, path, hist)
+
+ plan, err := rb.Plan("prod", "", "services")
+ if err != nil {
+ t.Fatalf("Plan: %v", err)
+ }
+ if plan.Target.SHA != "svcsha111" {
+ t.Errorf("target sha = %q, want svcsha111", plan.Target.SHA)
+ }
+ if plan.Target.Deployable != "services" {
+ t.Errorf("deployable = %q, want services", plan.Target.Deployable)
+ }
+}
+
+func TestResolveDefaultTarget_NoPriorErrors(t *testing.T) {
+ dir := t.TempDir()
+ path := writeManifest(t, dir, "newprodsha1234", "v2.0.0")
+ rb := newRollbacker(t, path, fakeHistory{})
+
+ if _, err := rb.Plan("prod", "", ""); err == nil {
+ t.Error("expected error when no prior version exists (env-scoped)")
+ }
+ if _, err := rb.Plan("prod", "", "services"); err == nil {
+ t.Error("expected error when no prior version exists (deployable-scoped)")
+ }
+}
+
+func TestReport_JSONOutput(t *testing.T) {
+ plan := &Plan{
+ Environment: "prod",
+ Target: Target{SHA: "abcdef1234567", Version: "v1.8.0", Source: "git-history"},
+ CurrentSHA: "newsha7654321",
+ CurrentVersion: "v2.0.0",
+ }
+ if err := report(plan, true, false); err != nil {
+ t.Fatalf("report json: %v", err)
+ }
+}
+
+func TestReport_TextNonNoOpAndDeployable(t *testing.T) {
+ plan := &Plan{
+ Environment: "prod",
+ Deployable: "services",
+ Target: Target{SHA: "abcdef1234567", Version: "v1.8.0", Source: "previous-ring"},
+ CurrentSHA: "newsha7654321",
+ CurrentVersion: "v2.0.0",
+ }
+ if err := report(plan, false, true); err != nil {
+ t.Fatalf("report text dry-run: %v", err)
+ }
+ if err := report(plan, false, false); err != nil {
+ t.Fatalf("report text apply: %v", err)
+ }
+}
+
+func TestRun_ErrorOnBadConfig(t *testing.T) {
+ err := run(runOptions{configPath: filepath.Join(t.TempDir(), "does-not-exist.yaml"), env: "prod"})
+ if err == nil {
+ t.Fatal("expected error for unreadable manifest")
+ }
+}
+
+func TestRunPreflight_JSONOutput(t *testing.T) {
+ dir := t.TempDir()
+ path := writeManifest(t, dir, "prodsha9999999", "v1.9.0")
+ err := runPreflight(preflightOptions{configPath: path, env: "prod", to: "v1.9.0", jsonOutput: true})
+ if err != nil {
+ t.Fatalf("runPreflight json: %v", err)
+ }
+}
+
+func TestRunPreflight_NewErrorWritesCannotProceed(t *testing.T) {
+ outFile := filepath.Join(t.TempDir(), "gha_output")
+ t.Setenv("GITHUB_OUTPUT", outFile)
+
+ err := runPreflight(preflightOptions{
+ configPath: filepath.Join(t.TempDir(), "missing.yaml"),
+ env: "prod",
+ ghaOutput: true,
+ })
+ if err == nil {
+ t.Fatal("expected error for unreadable manifest")
+ }
+ data, readErr := os.ReadFile(outFile)
+ if readErr != nil {
+ t.Fatalf("read gha output: %v", readErr)
+ }
+ if string(data) == "" {
+ t.Error("expected can_proceed=false written on New failure")
+ }
+}
+
+func TestRunFinalize_ErrorOnBadConfig(t *testing.T) {
+ err := runFinalize(finalizeOptions{
+ configPath: filepath.Join(t.TempDir(), "missing.yaml"),
+ env: "prod",
+ })
+ if err == nil {
+ t.Fatal("expected error for unreadable manifest")
+ }
+}
+
+func TestIsRealGitHub(t *testing.T) {
+ t.Setenv("GITHUB_SERVER_URL", "")
+ if !isRealGitHub() {
+ t.Error("empty GITHUB_SERVER_URL should be treated as real GitHub")
+ }
+ t.Setenv("GITHUB_SERVER_URL", "https://github.com")
+ if !isRealGitHub() {
+ t.Error("github.com should be real GitHub")
+ }
+ t.Setenv("GITHUB_SERVER_URL", "https://gitea.local")
+ if isRealGitHub() {
+ t.Error("gitea host should not be real GitHub")
+ }
+}
+
+func TestTrunkBranchFromEnv(t *testing.T) {
+ t.Setenv("GITHUB_REF", "refs/heads/release")
+ if got := trunkBranchFromEnv(); got != "release" {
+ t.Errorf("trunkBranchFromEnv() = %q, want release", got)
+ }
+ t.Setenv("GITHUB_REF", "some-branch")
+ if got := trunkBranchFromEnv(); got != "some-branch" {
+ t.Errorf("trunkBranchFromEnv() = %q, want some-branch", got)
+ }
+ t.Setenv("GITHUB_REF", "")
+ if got := trunkBranchFromEnv(); got != "main" {
+ t.Errorf("trunkBranchFromEnv() = %q, want main", got)
+ }
+}
+
+func TestWriteStateViaAPI_RequiresRepository(t *testing.T) {
+ t.Setenv("GITHUB_REPOSITORY", "")
+ err := writeStateViaAPI("manifest.yaml", "msg", statewrite.Identity{})
+ if err == nil {
+ t.Fatal("expected error when GITHUB_REPOSITORY is unset")
+ }
+}
+
+func TestCommitAndPush_NoChangesIsNoOp(t *testing.T) {
+ if _, err := exec.LookPath("git"); err != nil {
+ t.Skip("git not available")
+ }
+ dir := t.TempDir()
+ gitInit(t, dir)
+ gitCommitFile(t, dir, "manifest.yaml", manifestAt("prodsha9999999", "v1.9.0"), "seed")
+
+ t.Chdir(dir)
+ // The committed manifest is unchanged, so commitAndPush observes a clean tree
+ // and returns nil without attempting any push.
+ if err := commitAndPush("manifest.yaml", "prod", statewrite.Identity{}); err != nil {
+ t.Fatalf("commitAndPush no-op: %v", err)
+ }
+}
+
+func TestExtractEnvState_EdgeCases(t *testing.T) {
+ // Invalid YAML returns nil.
+ if s := extractEnvState([]byte("::: not yaml :::"), config.DefaultManifestKey, "prod"); s != nil {
+ t.Errorf("invalid yaml = %+v, want nil", s)
+ }
+ // Missing top-level key returns nil.
+ if s := extractEnvState([]byte("other:\n state: {}\n"), config.DefaultManifestKey, "prod"); s != nil {
+ t.Errorf("missing key = %+v, want nil", s)
+ }
+ // Env absent returns nil.
+ missingEnv := []byte("ci:\n state:\n dev:\n sha: x\n")
+ if s := extractEnvState(missingEnv, config.DefaultManifestKey, "prod"); s != nil {
+ t.Errorf("absent env = %+v, want nil", s)
+ }
+ // Empty (zero-value) env state returns nil.
+ emptyEnv := []byte("ci:\n state:\n prod: {}\n")
+ if s := extractEnvState(emptyEnv, config.DefaultManifestKey, "prod"); s != nil {
+ t.Errorf("empty env state = %+v, want nil", s)
+ }
+ // A populated env state is returned, and an empty manifest key defaults.
+ good := []byte("ci:\n state:\n prod:\n sha: prodsha9999999\n version: v1.9.0\n")
+ s := extractEnvState(good, "", "prod")
+ if s == nil || s.SHA != "prodsha9999999" {
+ t.Errorf("populated env state not returned: %+v", s)
+ }
+}
diff --git a/internal/version/command_test.go b/internal/version/command_test.go
new file mode 100644
index 0000000..5899ec6
--- /dev/null
+++ b/internal/version/command_test.go
@@ -0,0 +1,167 @@
+package version
+
+import (
+ "bytes"
+ "os"
+ "path/filepath"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestVersionNewCommand_Structure(t *testing.T) {
+ cmd := NewCommand()
+
+ assert.Equal(t, "next-version", cmd.Use)
+ assert.NotEmpty(t, cmd.Short)
+ assert.NotEmpty(t, cmd.Long)
+
+ assert.NotNil(t, cmd.Flags().Lookup("config"))
+ assert.NotNil(t, cmd.Flags().Lookup("environment"))
+ assert.NotNil(t, cmd.Flags().Lookup("base-sha"))
+ assert.NotNil(t, cmd.Flags().Lookup("head-sha"))
+ assert.NotNil(t, cmd.Flags().Lookup("json"))
+}
+
+func TestVersionNewCommand_RunE_BadConfigPath(t *testing.T) {
+ cmd := NewCommand()
+ var out bytes.Buffer
+ cmd.SetOut(&out)
+ cmd.SetErr(&out)
+ cmd.SetArgs([]string{
+ "--environment", "dev",
+ "--config", "/nonexistent/path/manifest.yaml",
+ })
+ err := cmd.Execute()
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "loading config")
+}
+
+func TestVersionNewCommand_RunE_MissingEnvironmentFlag(t *testing.T) {
+ cmd := NewCommand()
+ var out bytes.Buffer
+ cmd.SetOut(&out)
+ cmd.SetErr(&out)
+ // environment is required; omitting it produces an error before RunE
+ cmd.SetArgs([]string{"--config", "/some/path.yaml"})
+ err := cmd.Execute()
+ assert.Error(t, err)
+}
+
+// minimalConfig returns a valid manifest.yaml in a temp dir suitable for
+// exercising the version command RunE without hitting infrastructure.
+func minimalVersionConfig(t *testing.T, environments []string) string {
+ t.Helper()
+ tmpDir := t.TempDir()
+ configPath := filepath.Join(tmpDir, "manifest.yaml")
+
+ // Build environments list
+ envLines := ""
+ for _, e := range environments {
+ envLines += " - " + e + "\n"
+ }
+
+ content := "ci:\n config:\n trunk_branch: main\n environments:\n" + envLines
+ require.NoError(t, os.WriteFile(configPath, []byte(content), 0o600))
+ return configPath
+}
+
+func TestVersionNewCommand_RunE_EnvironmentNotFound(t *testing.T) {
+ configPath := minimalVersionConfig(t, []string{"dev", "test"})
+
+ cmd := NewCommand()
+ var out bytes.Buffer
+ cmd.SetOut(&out)
+ cmd.SetErr(&out)
+ cmd.SetArgs([]string{
+ "--environment", "staging",
+ "--config", configPath,
+ })
+ err := cmd.Execute()
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "environment")
+}
+
+func TestVersionNewCommand_RunE_ValidConfigTextOutput(t *testing.T) {
+ configPath := minimalVersionConfig(t, []string{"dev", "test"})
+
+ cmd := NewCommand()
+ var out bytes.Buffer
+ cmd.SetOut(&out)
+ cmd.SetErr(&out)
+ // HEAD~1..HEAD gets exactly one real commit; exercises the commit-parsing loop.
+ cmd.SetArgs([]string{
+ "--environment", "dev",
+ "--config", configPath,
+ "--base-sha", "HEAD~1",
+ "--head-sha", "HEAD",
+ })
+ // May succeed or fail depending on git availability; exercise the code path.
+ _ = cmd.Execute()
+}
+
+func TestVersionNewCommand_RunE_ValidConfigJSONOutput(t *testing.T) {
+ configPath := minimalVersionConfig(t, []string{"dev", "test"})
+
+ cmd := NewCommand()
+ var out bytes.Buffer
+ cmd.SetOut(&out)
+ cmd.SetErr(&out)
+ cmd.SetArgs([]string{
+ "--environment", "dev",
+ "--config", configPath,
+ "--base-sha", "HEAD~1",
+ "--head-sha", "HEAD",
+ "--json",
+ })
+ _ = cmd.Execute()
+}
+
+func TestVersionNewCommand_RunE_EmptyRange(t *testing.T) {
+ configPath := minimalVersionConfig(t, []string{"dev", "test"})
+
+ cmd := NewCommand()
+ var out bytes.Buffer
+ cmd.SetOut(&out)
+ cmd.SetErr(&out)
+ // HEAD..HEAD yields zero commits; exercises the no-baseSHA fallback branch.
+ cmd.SetArgs([]string{
+ "--environment", "dev",
+ "--config", configPath,
+ })
+ _ = cmd.Execute()
+}
+
+func TestVersionNewCommand_RunE_NoConfigFlag(t *testing.T) {
+ // When --config is omitted, FindConfigFile is called to locate a manifest.
+ // From the package test directory there is no .github/manifest.yaml, so
+ // config.Parse fails with "loading config".
+ cmd := NewCommand()
+ var out bytes.Buffer
+ cmd.SetOut(&out)
+ cmd.SetErr(&out)
+ cmd.SetArgs([]string{"--environment", "dev"})
+ err := cmd.Execute()
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "loading config")
+}
+
+func TestBumpTypeString(t *testing.T) {
+ tests := []struct {
+ bump BumpType
+ want string
+ }{
+ {BumpMajor, "major"},
+ {BumpMinor, "minor"},
+ {BumpPatch, "patch"},
+ {BumpNone, "none"},
+ {BumpType(99), "none"},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.want, func(t *testing.T) {
+ assert.Equal(t, tt.want, bumpTypeString(tt.bump))
+ })
+ }
+}
diff --git a/internal/version/version_coverage_test.go b/internal/version/version_coverage_test.go
new file mode 100644
index 0000000..2b1195e
--- /dev/null
+++ b/internal/version/version_coverage_test.go
@@ -0,0 +1,116 @@
+package version
+
+import (
+ "testing"
+
+ "github.com/stablekernel/cascade/internal/changelog"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestCalculateNext_InvalidNextEnvVersion(t *testing.T) {
+ calc := NewCalculator("v")
+ _, err := calc.CalculateNext("", "not-a-version", nil)
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "parsing next env version")
+}
+
+func TestCalculateNext_ChoreOnlyCommits(t *testing.T) {
+ // Chore commits yield BumpNone; when commits are present BumpNone is promoted
+ // to BumpPatch so that real work always produces a version increment.
+ calc := NewCalculator("v")
+ commits := []changelog.ConventionalCommit{
+ {Type: "chore", Description: "update dependencies"},
+ }
+ got, err := calc.CalculateNext("", "v1.0.0", commits)
+ require.NoError(t, err)
+ assert.Equal(t, "v1.0.1-rc.0", got.String())
+}
+
+func TestCalculateNext_UnparseableCurrentDevVersion(t *testing.T) {
+ // If currentDevVersion is non-empty but cannot be parsed, the RC counter
+ // resets to 0 rather than erroring.
+ calc := NewCalculator("v")
+ commits := []changelog.ConventionalCommit{
+ {Type: "feat", Description: "new feature"},
+ }
+ got, err := calc.CalculateNext("not-a-version", "v1.0.0", commits)
+ require.NoError(t, err)
+ assert.Equal(t, 0, got.PreRelease)
+}
+
+func TestCalculateNext_NoCommitsNoNextEnv(t *testing.T) {
+ // Zero commits and no next env version: no bump, but minimum v0.1.0 rule applies.
+ calc := NewCalculator("v")
+ got, err := calc.CalculateNext("", "", nil)
+ require.NoError(t, err)
+ assert.Equal(t, 0, got.Major)
+ assert.Equal(t, 1, got.Minor)
+ assert.Equal(t, 0, got.PreRelease)
+}
+
+func TestVersion_Compare_ReleaseVsPreRelease(t *testing.T) {
+ tests := []struct {
+ name string
+ a string
+ b string
+ want int
+ }{
+ {
+ name: "pre-release sorts before its release",
+ a: "v1.0.0-rc.0",
+ b: "v1.0.0",
+ want: -1,
+ },
+ {
+ name: "release sorts after any pre-release",
+ a: "v1.0.0",
+ b: "v1.0.0-rc.0",
+ want: 1,
+ },
+ {
+ name: "two equal releases",
+ a: "v1.0.0",
+ b: "v1.0.0",
+ want: 0,
+ },
+ {
+ name: "same rc without hotfix is equal to itself",
+ a: "v1.0.0-rc.2",
+ b: "v1.0.0-rc.2",
+ want: 0,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ va := mustParse(t, tt.a)
+ vb := mustParse(t, tt.b)
+ assert.Equal(t, tt.want, va.Compare(vb))
+ })
+ }
+}
+
+func TestVersion_Compare_MajorMinorPatch(t *testing.T) {
+ tests := []struct {
+ name string
+ a string
+ b string
+ want int
+ }{
+ {"major greater", "v2.0.0", "v1.0.0", 1},
+ {"major lesser", "v1.0.0", "v2.0.0", -1},
+ {"minor greater", "v1.2.0", "v1.1.0", 1},
+ {"minor lesser", "v1.1.0", "v1.2.0", -1},
+ {"patch greater", "v1.0.2", "v1.0.1", 1},
+ {"patch lesser", "v1.0.1", "v1.0.2", -1},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ va := mustParse(t, tt.a)
+ vb := mustParse(t, tt.b)
+ assert.Equal(t, tt.want, va.Compare(vb))
+ })
+ }
+}