From 60aa9be58848d300876eaf854379c2e9902268aa Mon Sep 17 00:00:00 2001 From: Pete Cornish Date: Mon, 6 Apr 2026 22:16:13 +0100 Subject: [PATCH 1/2] test: improve test coverage across core packages Add tests for stringutil, convcommits, semver, vcs, and changelog packages covering key behaviours, edge cases, and error conditions. --- changelog/read_test.go | 138 +++++++++++++++++ changelog/write_test.go | 54 +++++++ convcommits/commits_test_extra_test.go | 57 +++++++ semver/versions_test_extra_test.go | 80 ++++++++++ stringutil/utils_test.go | 95 ++++++++++++ vcs/commits_test_extra_test.go | 93 ++++++++++++ vcs/fetch_test.go | 109 ++++++++++++++ vcs/operations_test.go | 197 +++++++++++++++++++++++++ 8 files changed, 823 insertions(+) create mode 100644 changelog/read_test.go create mode 100644 changelog/write_test.go create mode 100644 convcommits/commits_test_extra_test.go create mode 100644 semver/versions_test_extra_test.go create mode 100644 stringutil/utils_test.go create mode 100644 vcs/commits_test_extra_test.go create mode 100644 vcs/fetch_test.go create mode 100644 vcs/operations_test.go diff --git a/changelog/read_test.go b/changelog/read_test.go new file mode 100644 index 0000000..79fbc98 --- /dev/null +++ b/changelog/read_test.go @@ -0,0 +1,138 @@ +package changelog + +import ( + "os" + "path" + "reflect" + "testing" +) + +func TestResolveChangelogFile(t *testing.T) { + tests := []struct { + name string + dir string + fileName string + want string + }{ + { + name: "simple filename", + dir: "/repo", + fileName: "CHANGELOG.md", + want: "/repo/CHANGELOG.md", + }, + { + name: "absolute path with forward slash", + dir: "/repo", + fileName: "/other/CHANGELOG.md", + want: "/other/CHANGELOG.md", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := ResolveChangelogFile(tt.dir, tt.fileName); got != tt.want { + t.Errorf("ResolveChangelogFile() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestReadFile(t *testing.T) { + dir := t.TempDir() + filePath := path.Join(dir, "test.md") + err := os.WriteFile(filePath, []byte("line1\nline2\nline3"), 0644) + if err != nil { + t.Fatal(err) + } + + got, err := ReadFile(filePath) + if err != nil { + t.Fatalf("ReadFile() error = %v", err) + } + want := []string{"line1", "line2", "line3"} + if !reflect.DeepEqual(got, want) { + t.Errorf("ReadFile() = %v, want %v", got, want) + } +} + +func TestReadFile_nonExistent(t *testing.T) { + _, err := ReadFile("/nonexistent/file.md") + if err == nil { + t.Error("ReadFile() expected error for non-existent file") + } +} + +func TestParseChangelog(t *testing.T) { + dir := t.TempDir() + filePath := path.Join(dir, "CHANGELOG.md") + content := `# Changelog + +## [1.0.0] - 2024-01-01 +### Added +- feat: something new + +## [0.9.0] - 2023-12-01 +### Fixed +- fix: old bug +` + err := os.WriteFile(filePath, []byte(content), 0644) + if err != nil { + t.Fatal(err) + } + + got, err := ParseChangelog(filePath, "1.0.0", false) + if err != nil { + t.Fatalf("ParseChangelog() error = %v", err) + } + want := []string{"### Added", "- feat: something new"} + if !reflect.DeepEqual(got, want) { + t.Errorf("ParseChangelog() = %v, want %v", got, want) + } +} + +func TestParseChangelog_withHeader(t *testing.T) { + dir := t.TempDir() + filePath := path.Join(dir, "CHANGELOG.md") + content := `# Changelog + +## [1.0.0] - 2024-01-01 +### Added +- feat: something new + +## [0.9.0] - 2023-12-01 +### Fixed +- fix: old bug +` + err := os.WriteFile(filePath, []byte(content), 0644) + if err != nil { + t.Fatal(err) + } + + got, err := ParseChangelog(filePath, "1.0.0", true) + if err != nil { + t.Fatalf("ParseChangelog() error = %v", err) + } + if len(got) == 0 { + t.Fatal("ParseChangelog() returned empty result") + } + if got[0] != "## [1.0.0] - 2024-01-01" { + t.Errorf("ParseChangelog() first line = %v, want version header", got[0]) + } +} + +func Test_readChanges_firstVersion(t *testing.T) { + lines := []string{ + "# Changelog", + "", + "## [1.0.0] - 2024-01-01", + "### Added", + "- feat: foo", + "", + "## [0.9.0] - 2023-12-01", + "- fix: bar", + } + got := readChanges(lines, "", false) + want := []string{"### Added", "- feat: foo"} + if !reflect.DeepEqual(got, want) { + t.Errorf("readChanges() = %v, want %v", got, want) + } +} diff --git a/changelog/write_test.go b/changelog/write_test.go new file mode 100644 index 0000000..a0da78c --- /dev/null +++ b/changelog/write_test.go @@ -0,0 +1,54 @@ +package changelog + +import ( + "os" + "path" + "testing" +) + +func TestWriteChangelog(t *testing.T) { + dir := t.TempDir() + filePath := path.Join(dir, "CHANGELOG.md") + + content := "# Changelog\n\n## [1.0.0]\n- feat: foo\n" + err := WriteChangelog(filePath, content) + if err != nil { + t.Fatalf("WriteChangelog() error = %v", err) + } + + got, err := os.ReadFile(filePath) + if err != nil { + t.Fatal(err) + } + // WriteChangelog appends a newline + want := content + "\n" + if string(got) != want { + t.Errorf("WriteChangelog() file content = %q, want %q", string(got), want) + } +} + +func TestWriteChangelog_overwrite(t *testing.T) { + dir := t.TempDir() + filePath := path.Join(dir, "CHANGELOG.md") + + // write initial content + err := os.WriteFile(filePath, []byte("old content"), 0644) + if err != nil { + t.Fatal(err) + } + + newContent := "# New Changelog" + err = WriteChangelog(filePath, newContent) + if err != nil { + t.Fatalf("WriteChangelog() error = %v", err) + } + + got, err := os.ReadFile(filePath) + if err != nil { + t.Fatal(err) + } + want := newContent + "\n" + if string(got) != want { + t.Errorf("WriteChangelog() file content = %q, want %q", string(got), want) + } +} diff --git a/convcommits/commits_test_extra_test.go b/convcommits/commits_test_extra_test.go new file mode 100644 index 0000000..e04cec1 --- /dev/null +++ b/convcommits/commits_test_extra_test.go @@ -0,0 +1,57 @@ +package convcommits + +import ( + "sort" + "testing" +) + +func TestCategoriseByType_scopedCommits(t *testing.T) { + commits := []string{"feat(api): add endpoint", "fix(ui): correct layout"} + got := CategoriseByType(commits) + + if len(got) != 2 { + t.Fatalf("expected 2 categories, got %d", len(got)) + } + if _, ok := got["feat"]; !ok { + t.Errorf("expected 'feat' category, got keys: %v", got) + } + if _, ok := got["fix"]; !ok { + t.Errorf("expected 'fix' category, got keys: %v", got) + } +} + +func TestCategoriseByType_breakingChange(t *testing.T) { + commits := []string{"feat!: breaking feature"} + got := CategoriseByType(commits) + + if _, ok := got["BREAKING CHANGE"]; !ok { + t.Errorf("expected 'BREAKING CHANGE' category, got keys: %v", got) + } +} + +func TestCategoriseByType_noPrefix(t *testing.T) { + commits := []string{"some random commit message"} + got := CategoriseByType(commits) + + if _, ok := got[""]; !ok { + t.Errorf("expected empty prefix category, got keys: %v", got) + } +} + +func TestDetermineTypes(t *testing.T) { + commits := []string{"feat: foo", "fix: bar", "feat: baz"} + got := DetermineTypes(commits) + sort.Strings(got) + + want := []string{"feat", "fix"} + sort.Strings(want) + + if len(got) != len(want) { + t.Fatalf("DetermineTypes() length = %d, want %d", len(got), len(want)) + } + for i := range got { + if got[i] != want[i] { + t.Errorf("DetermineTypes()[%d] = %v, want %v", i, got[i], want[i]) + } + } +} diff --git a/semver/versions_test_extra_test.go b/semver/versions_test_extra_test.go new file mode 100644 index 0000000..ac802f7 --- /dev/null +++ b/semver/versions_test_extra_test.go @@ -0,0 +1,80 @@ +package semver + +import "testing" + +func TestGetNextVersion_withVPrefix(t *testing.T) { + got := GetNextVersion("1.2.3", true, []string{"feat: new feature"}) + want := "v1.3.0" + if got != want { + t.Errorf("GetNextVersion() with vPrefix = %v, want %v", got, want) + } +} + +func TestGetNextVersion_noChanges(t *testing.T) { + got := GetNextVersion("1.2.3", false, []string{"unknown: something"}) + want := "" + if got != want { + t.Errorf("GetNextVersion() with no recognised changes = %v, want %v", got, want) + } +} + +func TestDetermineChangeType_allPatchTypes(t *testing.T) { + patchTypes := []string{"build", "chore", "ci", "docs", "fix", "refactor", "security", "style", "test"} + for _, pt := range patchTypes { + t.Run(pt, func(t *testing.T) { + got := DetermineChangeType([]string{pt}) + if got != ComponentPatch { + t.Errorf("DetermineChangeType(%v) = %v, want %v", pt, got, ComponentPatch) + } + }) + } +} + +func TestDetermineChangeType_none(t *testing.T) { + got := DetermineChangeType([]string{"unknown"}) + if got != ComponentNone { + t.Errorf("DetermineChangeType(unknown) = %v, want %v", got, ComponentNone) + } +} + +func TestDetermineChangeType_majorTakesPrecedence(t *testing.T) { + got := DetermineChangeType([]string{"feat", "BREAKING CHANGE", "fix"}) + if got != ComponentMajor { + t.Errorf("DetermineChangeType() = %v, want %v", got, ComponentMajor) + } +} + +func TestBumpVersion(t *testing.T) { + tests := []struct { + name string + version string + component Component + want string + }{ + { + name: "bump major resets minor and patch", + version: "1.5.9", + component: ComponentMajor, + want: "2.0.0", + }, + { + name: "bump minor resets patch", + version: "1.5.9", + component: ComponentMinor, + want: "1.6.0", + }, + { + name: "bump patch only", + version: "1.5.9", + component: ComponentPatch, + want: "1.5.10", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := bumpVersion(tt.version, tt.component); got != tt.want { + t.Errorf("bumpVersion() = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/stringutil/utils_test.go b/stringutil/utils_test.go new file mode 100644 index 0000000..606ce5a --- /dev/null +++ b/stringutil/utils_test.go @@ -0,0 +1,95 @@ +package stringutil + +import ( + "reflect" + "testing" +) + +func TestContainsIgnoreCase(t *testing.T) { + tests := []struct { + name string + orig []string + search []string + want bool + }{ + { + name: "exact match", + orig: []string{"foo", "bar"}, + search: []string{"foo"}, + want: true, + }, + { + name: "case insensitive match", + orig: []string{"Foo", "Bar"}, + search: []string{"foo"}, + want: true, + }, + { + name: "no match", + orig: []string{"foo", "bar"}, + search: []string{"baz"}, + want: false, + }, + { + name: "empty orig", + orig: []string{}, + search: []string{"foo"}, + want: false, + }, + { + name: "empty search", + orig: []string{"foo"}, + search: []string{}, + want: false, + }, + { + name: "multiple search terms with one match", + orig: []string{"foo", "bar"}, + search: []string{"baz", "bar"}, + want: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := ContainsIgnoreCase(tt.orig, tt.search...); got != tt.want { + t.Errorf("ContainsIgnoreCase() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestUnique(t *testing.T) { + tests := []struct { + name string + s []string + want []string + }{ + { + name: "no duplicates", + s: []string{"foo", "bar"}, + want: []string{"foo", "bar"}, + }, + { + name: "with duplicates", + s: []string{"foo", "bar", "foo"}, + want: []string{"foo", "bar"}, + }, + { + name: "case insensitive duplicates", + s: []string{"Foo", "foo"}, + want: []string{"Foo"}, + }, + { + name: "empty slice", + s: []string{}, + want: nil, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := Unique(tt.s); !reflect.DeepEqual(got, tt.want) { + t.Errorf("Unique() = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/vcs/commits_test_extra_test.go b/vcs/commits_test_extra_test.go new file mode 100644 index 0000000..d1cf192 --- /dev/null +++ b/vcs/commits_test_extra_test.go @@ -0,0 +1,93 @@ +package vcs + +import ( + "reflect" + "regexp" + "testing" +) + +func TestFlattenCommits(t *testing.T) { + tags := []TagCommits{ + { + TagMeta: TagMeta{Name: "v1"}, + Commits: []string{"feat: a", "fix: b"}, + }, + { + TagMeta: TagMeta{Name: "v2"}, + Commits: []string{"chore: c"}, + }, + } + got := FlattenCommits(&tags) + want := []string{"feat: a", "fix: b", "chore: c"} + if !reflect.DeepEqual(got, want) { + t.Errorf("FlattenCommits() = %v, want %v", got, want) + } +} + +func TestFlattenCommits_empty(t *testing.T) { + tags := []TagCommits{} + got := FlattenCommits(&tags) + if len(got) != 0 { + t.Errorf("FlattenCommits() = %v, want empty", got) + } +} + +func Test_shouldInclude(t *testing.T) { + tests := []struct { + name string + message string + excludes []*regexp.Regexp + want bool + }{ + { + name: "no excludes", + message: "feat: something", + excludes: nil, + want: true, + }, + { + name: "matching exclude", + message: "build: release v1.0.0", + excludes: []*regexp.Regexp{regexp.MustCompile(`^build: release`)}, + want: false, + }, + { + name: "non-matching exclude", + message: "feat: new feature", + excludes: []*regexp.Regexp{regexp.MustCompile(`^build: release`)}, + want: true, + }, + { + name: "multiple excludes with one match", + message: "chore: bump deps", + excludes: []*regexp.Regexp{ + regexp.MustCompile(`^build: release`), + regexp.MustCompile(`^chore: bump`), + }, + want: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := shouldInclude(tt.message, tt.excludes); got != tt.want { + t.Errorf("shouldInclude() = %v, want %v", got, tt.want) + } + }) + } +} + +func Test_getShortMessage_singleLine(t *testing.T) { + got := getShortMessage("simple message") + want := "simple message" + if got != want { + t.Errorf("getShortMessage() = %v, want %v", got, want) + } +} + +func Test_getShortMessage_withLeadingWhitespace(t *testing.T) { + got := getShortMessage(" message with spaces ") + want := "message with spaces" + if got != want { + t.Errorf("getShortMessage() = %v, want %v", got, want) + } +} diff --git a/vcs/fetch_test.go b/vcs/fetch_test.go new file mode 100644 index 0000000..5b204e0 --- /dev/null +++ b/vcs/fetch_test.go @@ -0,0 +1,109 @@ +package vcs + +import ( + "github.com/release-tools/since/cfg" + "testing" +) + +func TestFetchCommitMessages(t *testing.T) { + repoDir := createTestRepo(t) + + commits, err := FetchCommitMessages(cfg.SinceConfig{}, CommitConfig{}, repoDir, "", "0.0.1") + if err != nil { + t.Fatalf("FetchCommitMessages() error = %v", err) + } + if len(commits) == 0 { + t.Fatal("FetchCommitMessages() returned no commits") + } + found := false + for _, c := range commits { + if c == "second update" { + found = true + break + } + } + if !found { + t.Errorf("FetchCommitMessages() did not contain 'second update', got %v", commits) + } +} + +func TestFetchCommitMessages_allCommits(t *testing.T) { + repoDir := createTestRepo(t) + + commits, err := FetchCommitMessages(cfg.SinceConfig{}, CommitConfig{}, repoDir, "", "") + if err != nil { + t.Fatalf("FetchCommitMessages() error = %v", err) + } + if len(commits) < 2 { + t.Errorf("FetchCommitMessages() returned %d commits, want at least 2", len(commits)) + } +} + +func TestFetchCommitsByTag(t *testing.T) { + repoDir := createTestRepo(t) + + tagCommits, err := FetchCommitsByTag(cfg.SinceConfig{}, CommitConfig{}, repoDir, "", "0.0.1") + if err != nil { + t.Fatalf("FetchCommitsByTag() error = %v", err) + } + if tagCommits == nil || len(*tagCommits) == 0 { + t.Fatal("FetchCommitsByTag() returned no tag commits") + } +} + +func TestFetchCommitMessages_withExcludes(t *testing.T) { + repoDir := createTestRepo(t) + + config := cfg.SinceConfig{ + Ignore: []string{"^second"}, + } + commits, err := FetchCommitMessages(config, CommitConfig{}, repoDir, "", "0.0.1") + if err != nil { + t.Fatalf("FetchCommitMessages() error = %v", err) + } + for _, c := range commits { + if c == "second update" { + t.Error("FetchCommitMessages() should have excluded 'second update'") + } + } +} + +func TestFetchCommitMessages_excludeTagCommits(t *testing.T) { + repoDir := createTestRepo(t) + + commitCfg := CommitConfig{ExcludeTagCommits: true} + commits, err := FetchCommitMessages(cfg.SinceConfig{}, commitCfg, repoDir, "", "") + if err != nil { + t.Fatalf("FetchCommitMessages() error = %v", err) + } + // with tag commits excluded, should have fewer commits + allCommits, _ := FetchCommitMessages(cfg.SinceConfig{}, CommitConfig{}, repoDir, "", "") + if len(commits) > len(allCommits) { + t.Errorf("excluding tag commits should not increase count: excluded=%d, all=%d", len(commits), len(allCommits)) + } +} + +func TestFetchCommitMessages_uniqueOnly(t *testing.T) { + repoDir := createTestRepo(t) + + commitCfg := CommitConfig{UniqueOnly: true} + commits, err := FetchCommitMessages(cfg.SinceConfig{}, commitCfg, repoDir, "", "") + if err != nil { + t.Fatalf("FetchCommitMessages() error = %v", err) + } + // verify no duplicates + seen := make(map[string]bool) + for _, c := range commits { + if seen[c] { + t.Errorf("FetchCommitMessages() with UniqueOnly has duplicate: %v", c) + } + seen[c] = true + } +} + +func TestFetchCommitMessages_invalidRepo(t *testing.T) { + _, err := FetchCommitMessages(cfg.SinceConfig{}, CommitConfig{}, t.TempDir(), "", "") + if err == nil { + t.Error("FetchCommitMessages() expected error for invalid repo") + } +} diff --git a/vcs/operations_test.go b/vcs/operations_test.go new file mode 100644 index 0000000..d032689 --- /dev/null +++ b/vcs/operations_test.go @@ -0,0 +1,197 @@ +package vcs + +import ( + "github.com/go-git/go-git/v5" + "github.com/go-git/go-git/v5/plumbing/object" + "github.com/release-tools/since/cfg" + "os" + "path" + "testing" + "time" +) + +func TestGetHeadSha(t *testing.T) { + repoDir := createTestRepo(t) + + sha, err := GetHeadSha(repoDir) + if err != nil { + t.Fatalf("GetHeadSha() error = %v", err) + } + if len(sha) != 40 { + t.Errorf("GetHeadSha() sha length = %d, want 40", len(sha)) + } +} + +func TestGetHeadSha_invalidRepo(t *testing.T) { + _, err := GetHeadSha(t.TempDir()) + if err == nil { + t.Error("GetHeadSha() expected error for invalid repo") + } +} + +func TestCheckBranch_noBranchRequired(t *testing.T) { + repoDir := createTestRepo(t) + config := cfg.SinceConfig{} + + err := CheckBranch(repoDir, config) + if err != nil { + t.Errorf("CheckBranch() with no required branch error = %v", err) + } +} + +func TestCheckBranch_wrongBranch(t *testing.T) { + repoDir := createTestRepo(t) + config := cfg.SinceConfig{RequireBranch: "release"} + + err := CheckBranch(repoDir, config) + if err == nil { + t.Error("CheckBranch() expected error for wrong branch") + } +} + +func TestCommitChangelog(t *testing.T) { + repoDir := createTestRepo(t) + + changelogPath := path.Join(repoDir, "CHANGELOG.md") + err := os.WriteFile(changelogPath, []byte("# Changelog\n"), 0644) + if err != nil { + t.Fatal(err) + } + + // stage the file first via worktree + repo, err := git.PlainOpen(repoDir) + if err != nil { + t.Fatal(err) + } + w, err := repo.Worktree() + if err != nil { + t.Fatal(err) + } + _, err = w.Add("CHANGELOG.md") + if err != nil { + t.Fatal(err) + } + + sha, err := CommitChangelog(repoDir, changelogPath, "1.0.0") + if err != nil { + t.Fatalf("CommitChangelog() error = %v", err) + } + if len(sha) != 40 { + t.Errorf("CommitChangelog() sha length = %d, want 40", len(sha)) + } +} + +func TestTagRelease(t *testing.T) { + repoDir := createTestRepo(t) + + sha, err := GetHeadSha(repoDir) + if err != nil { + t.Fatal(err) + } + + err = TagRelease(repoDir, sha, "v1.0.0") + if err != nil { + t.Fatalf("TagRelease() error = %v", err) + } + + // reset cached tags + earliestTag = "" + latestTag = "" + + // verify tag exists + got, err := GetLatestTag(repoDir, TagOrderSemver) + if err != nil { + t.Fatal(err) + } + if got != "v1.0.0" { + t.Errorf("TagRelease() latest tag = %v, want v1.0.0", got) + } + + // reset cached tags for other tests + earliestTag = "" + latestTag = "" +} + +func TestGetEarliestTag(t *testing.T) { + repoDir := createTestRepo(t) + + // reset cached tags + earliestTag = "" + + got, err := GetEarliestTag(repoDir, TagOrderSemver) + if err != nil { + t.Fatalf("GetEarliestTag() error = %v", err) + } + if got != "0.0.1" { + t.Errorf("GetEarliestTag() = %v, want 0.0.1", got) + } + + // reset cached tags for other tests + earliestTag = "" +} + +func TestGetLatestTag(t *testing.T) { + repoDir := createTestRepo(t) + + // reset cached tags + latestTag = "" + + got, err := GetLatestTag(repoDir, TagOrderSemver) + if err != nil { + t.Fatalf("GetLatestTag() error = %v", err) + } + if got != "0.1.0" { + t.Errorf("GetLatestTag() = %v, want 0.1.0", got) + } + + // reset cached tags for other tests + latestTag = "" +} + +// createTestRepoForOps creates a minimal test repo with two tags. +func createTestRepoForOps(t *testing.T) string { + repoDir := t.TempDir() + + repoPathToReadme := path.Join(repoDir, "README.md") + readme, err := os.Create(repoPathToReadme) + if err != nil { + t.Fatal(err) + } + + repo, err := git.PlainInit(repoDir, false) + if err != nil { + t.Fatal(err) + } + w, err := repo.Worktree() + if err != nil { + t.Fatal(err) + } + + _, err = readme.WriteString("first update") + if err != nil { + t.Fatal(err) + } + _, err = w.Add("README.md") + if err != nil { + t.Fatal(err) + } + sig := &object.Signature{ + Name: "user", + Email: "user@example.com", + When: time.Now(), + } + c, err := w.Commit("initial commit", &git.CommitOptions{ + Author: sig, + Committer: sig, + }) + if err != nil { + t.Fatal(err) + } + + _, err = repo.CreateTag("0.0.1", c, nil) + if err != nil { + t.Fatal(err) + } + + return repoDir +} From 4651bb5223f5f3dbbfac0bd101817b8b8f845709 Mon Sep 17 00:00:00 2001 From: Pete Cornish Date: Tue, 7 Apr 2026 00:45:09 +0100 Subject: [PATCH 2/2] fix(test): configure git user in TestCommitChangelog for CI The test relied on global git config for author identity, which is not available in CI environments. --- .gitignore | 1 + convcommits/commits_test.go | 52 ++++++++++++++ convcommits/commits_test_extra_test.go | 57 ---------------- semver/versions_test.go | 77 +++++++++++++++++++++ semver/versions_test_extra_test.go | 80 ---------------------- vcs/commits_test.go | 92 ++++++++++++++++++++++++- vcs/commits_test_extra_test.go | 93 -------------------------- vcs/operations_test.go | 17 +++-- 8 files changed, 231 insertions(+), 238 deletions(-) delete mode 100644 convcommits/commits_test_extra_test.go delete mode 100644 semver/versions_test_extra_test.go delete mode 100644 vcs/commits_test_extra_test.go diff --git a/.gitignore b/.gitignore index 1cc0a96..be06adb 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,4 @@ /.idea/ /dist/ since +.claude/*.local.json diff --git a/convcommits/commits_test.go b/convcommits/commits_test.go index 4538926..31e04ff 100644 --- a/convcommits/commits_test.go +++ b/convcommits/commits_test.go @@ -18,6 +18,7 @@ package convcommits import ( "reflect" + "sort" "testing" ) @@ -56,3 +57,54 @@ func TestCategoriseByType(t *testing.T) { }) } } + +func TestCategoriseByType_scopedCommits(t *testing.T) { + commits := []string{"feat(api): add endpoint", "fix(ui): correct layout"} + got := CategoriseByType(commits) + + if len(got) != 2 { + t.Fatalf("expected 2 categories, got %d", len(got)) + } + if _, ok := got["feat"]; !ok { + t.Errorf("expected 'feat' category, got keys: %v", got) + } + if _, ok := got["fix"]; !ok { + t.Errorf("expected 'fix' category, got keys: %v", got) + } +} + +func TestCategoriseByType_breakingChange(t *testing.T) { + commits := []string{"feat!: breaking feature"} + got := CategoriseByType(commits) + + if _, ok := got["BREAKING CHANGE"]; !ok { + t.Errorf("expected 'BREAKING CHANGE' category, got keys: %v", got) + } +} + +func TestCategoriseByType_noPrefix(t *testing.T) { + commits := []string{"some random commit message"} + got := CategoriseByType(commits) + + if _, ok := got[""]; !ok { + t.Errorf("expected empty prefix category, got keys: %v", got) + } +} + +func TestDetermineTypes(t *testing.T) { + commits := []string{"feat: foo", "fix: bar", "feat: baz"} + got := DetermineTypes(commits) + sort.Strings(got) + + want := []string{"feat", "fix"} + sort.Strings(want) + + if len(got) != len(want) { + t.Fatalf("DetermineTypes() length = %d, want %d", len(got), len(want)) + } + for i := range got { + if got[i] != want[i] { + t.Errorf("DetermineTypes()[%d] = %v, want %v", i, got[i], want[i]) + } + } +} diff --git a/convcommits/commits_test_extra_test.go b/convcommits/commits_test_extra_test.go deleted file mode 100644 index e04cec1..0000000 --- a/convcommits/commits_test_extra_test.go +++ /dev/null @@ -1,57 +0,0 @@ -package convcommits - -import ( - "sort" - "testing" -) - -func TestCategoriseByType_scopedCommits(t *testing.T) { - commits := []string{"feat(api): add endpoint", "fix(ui): correct layout"} - got := CategoriseByType(commits) - - if len(got) != 2 { - t.Fatalf("expected 2 categories, got %d", len(got)) - } - if _, ok := got["feat"]; !ok { - t.Errorf("expected 'feat' category, got keys: %v", got) - } - if _, ok := got["fix"]; !ok { - t.Errorf("expected 'fix' category, got keys: %v", got) - } -} - -func TestCategoriseByType_breakingChange(t *testing.T) { - commits := []string{"feat!: breaking feature"} - got := CategoriseByType(commits) - - if _, ok := got["BREAKING CHANGE"]; !ok { - t.Errorf("expected 'BREAKING CHANGE' category, got keys: %v", got) - } -} - -func TestCategoriseByType_noPrefix(t *testing.T) { - commits := []string{"some random commit message"} - got := CategoriseByType(commits) - - if _, ok := got[""]; !ok { - t.Errorf("expected empty prefix category, got keys: %v", got) - } -} - -func TestDetermineTypes(t *testing.T) { - commits := []string{"feat: foo", "fix: bar", "feat: baz"} - got := DetermineTypes(commits) - sort.Strings(got) - - want := []string{"feat", "fix"} - sort.Strings(want) - - if len(got) != len(want) { - t.Fatalf("DetermineTypes() length = %d, want %d", len(got), len(want)) - } - for i := range got { - if got[i] != want[i] { - t.Errorf("DetermineTypes()[%d] = %v, want %v", i, got[i], want[i]) - } - } -} diff --git a/semver/versions_test.go b/semver/versions_test.go index 32d3f86..18d46be 100644 --- a/semver/versions_test.go +++ b/semver/versions_test.go @@ -120,3 +120,80 @@ func TestDetermineChangeType(t *testing.T) { }) } } + +func TestGetNextVersion_withVPrefix(t *testing.T) { + got := GetNextVersion("1.2.3", true, []string{"feat: new feature"}) + want := "v1.3.0" + if got != want { + t.Errorf("GetNextVersion() with vPrefix = %v, want %v", got, want) + } +} + +func TestGetNextVersion_noChanges(t *testing.T) { + got := GetNextVersion("1.2.3", false, []string{"unknown: something"}) + want := "" + if got != want { + t.Errorf("GetNextVersion() with no recognised changes = %v, want %v", got, want) + } +} + +func TestDetermineChangeType_allPatchTypes(t *testing.T) { + patchTypes := []string{"build", "chore", "ci", "docs", "fix", "refactor", "security", "style", "test"} + for _, pt := range patchTypes { + t.Run(pt, func(t *testing.T) { + got := DetermineChangeType([]string{pt}) + if got != ComponentPatch { + t.Errorf("DetermineChangeType(%v) = %v, want %v", pt, got, ComponentPatch) + } + }) + } +} + +func TestDetermineChangeType_none(t *testing.T) { + got := DetermineChangeType([]string{"unknown"}) + if got != ComponentNone { + t.Errorf("DetermineChangeType(unknown) = %v, want %v", got, ComponentNone) + } +} + +func TestDetermineChangeType_majorTakesPrecedence(t *testing.T) { + got := DetermineChangeType([]string{"feat", "BREAKING CHANGE", "fix"}) + if got != ComponentMajor { + t.Errorf("DetermineChangeType() = %v, want %v", got, ComponentMajor) + } +} + +func TestBumpVersion(t *testing.T) { + tests := []struct { + name string + version string + component Component + want string + }{ + { + name: "bump major resets minor and patch", + version: "1.5.9", + component: ComponentMajor, + want: "2.0.0", + }, + { + name: "bump minor resets patch", + version: "1.5.9", + component: ComponentMinor, + want: "1.6.0", + }, + { + name: "bump patch only", + version: "1.5.9", + component: ComponentPatch, + want: "1.5.10", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := bumpVersion(tt.version, tt.component); got != tt.want { + t.Errorf("bumpVersion() = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/semver/versions_test_extra_test.go b/semver/versions_test_extra_test.go deleted file mode 100644 index ac802f7..0000000 --- a/semver/versions_test_extra_test.go +++ /dev/null @@ -1,80 +0,0 @@ -package semver - -import "testing" - -func TestGetNextVersion_withVPrefix(t *testing.T) { - got := GetNextVersion("1.2.3", true, []string{"feat: new feature"}) - want := "v1.3.0" - if got != want { - t.Errorf("GetNextVersion() with vPrefix = %v, want %v", got, want) - } -} - -func TestGetNextVersion_noChanges(t *testing.T) { - got := GetNextVersion("1.2.3", false, []string{"unknown: something"}) - want := "" - if got != want { - t.Errorf("GetNextVersion() with no recognised changes = %v, want %v", got, want) - } -} - -func TestDetermineChangeType_allPatchTypes(t *testing.T) { - patchTypes := []string{"build", "chore", "ci", "docs", "fix", "refactor", "security", "style", "test"} - for _, pt := range patchTypes { - t.Run(pt, func(t *testing.T) { - got := DetermineChangeType([]string{pt}) - if got != ComponentPatch { - t.Errorf("DetermineChangeType(%v) = %v, want %v", pt, got, ComponentPatch) - } - }) - } -} - -func TestDetermineChangeType_none(t *testing.T) { - got := DetermineChangeType([]string{"unknown"}) - if got != ComponentNone { - t.Errorf("DetermineChangeType(unknown) = %v, want %v", got, ComponentNone) - } -} - -func TestDetermineChangeType_majorTakesPrecedence(t *testing.T) { - got := DetermineChangeType([]string{"feat", "BREAKING CHANGE", "fix"}) - if got != ComponentMajor { - t.Errorf("DetermineChangeType() = %v, want %v", got, ComponentMajor) - } -} - -func TestBumpVersion(t *testing.T) { - tests := []struct { - name string - version string - component Component - want string - }{ - { - name: "bump major resets minor and patch", - version: "1.5.9", - component: ComponentMajor, - want: "2.0.0", - }, - { - name: "bump minor resets patch", - version: "1.5.9", - component: ComponentMinor, - want: "1.6.0", - }, - { - name: "bump patch only", - version: "1.5.9", - component: ComponentPatch, - want: "1.5.10", - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - if got := bumpVersion(tt.version, tt.component); got != tt.want { - t.Errorf("bumpVersion() = %v, want %v", got, tt.want) - } - }) - } -} diff --git a/vcs/commits_test.go b/vcs/commits_test.go index b0c88bb..48fb425 100644 --- a/vcs/commits_test.go +++ b/vcs/commits_test.go @@ -16,7 +16,11 @@ limitations under the License. package vcs -import "testing" +import ( + "reflect" + "regexp" + "testing" +) func Test_getShortMessage(t *testing.T) { type args struct { @@ -50,3 +54,89 @@ func Test_getShortMessage(t *testing.T) { }) } } + +func TestFlattenCommits(t *testing.T) { + tags := []TagCommits{ + { + TagMeta: TagMeta{Name: "v1"}, + Commits: []string{"feat: a", "fix: b"}, + }, + { + TagMeta: TagMeta{Name: "v2"}, + Commits: []string{"chore: c"}, + }, + } + got := FlattenCommits(&tags) + want := []string{"feat: a", "fix: b", "chore: c"} + if !reflect.DeepEqual(got, want) { + t.Errorf("FlattenCommits() = %v, want %v", got, want) + } +} + +func TestFlattenCommits_empty(t *testing.T) { + tags := []TagCommits{} + got := FlattenCommits(&tags) + if len(got) != 0 { + t.Errorf("FlattenCommits() = %v, want empty", got) + } +} + +func Test_shouldInclude(t *testing.T) { + tests := []struct { + name string + message string + excludes []*regexp.Regexp + want bool + }{ + { + name: "no excludes", + message: "feat: something", + excludes: nil, + want: true, + }, + { + name: "matching exclude", + message: "build: release v1.0.0", + excludes: []*regexp.Regexp{regexp.MustCompile(`^build: release`)}, + want: false, + }, + { + name: "non-matching exclude", + message: "feat: new feature", + excludes: []*regexp.Regexp{regexp.MustCompile(`^build: release`)}, + want: true, + }, + { + name: "multiple excludes with one match", + message: "chore: bump deps", + excludes: []*regexp.Regexp{ + regexp.MustCompile(`^build: release`), + regexp.MustCompile(`^chore: bump`), + }, + want: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := shouldInclude(tt.message, tt.excludes); got != tt.want { + t.Errorf("shouldInclude() = %v, want %v", got, tt.want) + } + }) + } +} + +func Test_getShortMessage_singleLine(t *testing.T) { + got := getShortMessage("simple message") + want := "simple message" + if got != want { + t.Errorf("getShortMessage() = %v, want %v", got, want) + } +} + +func Test_getShortMessage_withLeadingWhitespace(t *testing.T) { + got := getShortMessage(" message with spaces ") + want := "message with spaces" + if got != want { + t.Errorf("getShortMessage() = %v, want %v", got, want) + } +} diff --git a/vcs/commits_test_extra_test.go b/vcs/commits_test_extra_test.go deleted file mode 100644 index d1cf192..0000000 --- a/vcs/commits_test_extra_test.go +++ /dev/null @@ -1,93 +0,0 @@ -package vcs - -import ( - "reflect" - "regexp" - "testing" -) - -func TestFlattenCommits(t *testing.T) { - tags := []TagCommits{ - { - TagMeta: TagMeta{Name: "v1"}, - Commits: []string{"feat: a", "fix: b"}, - }, - { - TagMeta: TagMeta{Name: "v2"}, - Commits: []string{"chore: c"}, - }, - } - got := FlattenCommits(&tags) - want := []string{"feat: a", "fix: b", "chore: c"} - if !reflect.DeepEqual(got, want) { - t.Errorf("FlattenCommits() = %v, want %v", got, want) - } -} - -func TestFlattenCommits_empty(t *testing.T) { - tags := []TagCommits{} - got := FlattenCommits(&tags) - if len(got) != 0 { - t.Errorf("FlattenCommits() = %v, want empty", got) - } -} - -func Test_shouldInclude(t *testing.T) { - tests := []struct { - name string - message string - excludes []*regexp.Regexp - want bool - }{ - { - name: "no excludes", - message: "feat: something", - excludes: nil, - want: true, - }, - { - name: "matching exclude", - message: "build: release v1.0.0", - excludes: []*regexp.Regexp{regexp.MustCompile(`^build: release`)}, - want: false, - }, - { - name: "non-matching exclude", - message: "feat: new feature", - excludes: []*regexp.Regexp{regexp.MustCompile(`^build: release`)}, - want: true, - }, - { - name: "multiple excludes with one match", - message: "chore: bump deps", - excludes: []*regexp.Regexp{ - regexp.MustCompile(`^build: release`), - regexp.MustCompile(`^chore: bump`), - }, - want: false, - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - if got := shouldInclude(tt.message, tt.excludes); got != tt.want { - t.Errorf("shouldInclude() = %v, want %v", got, tt.want) - } - }) - } -} - -func Test_getShortMessage_singleLine(t *testing.T) { - got := getShortMessage("simple message") - want := "simple message" - if got != want { - t.Errorf("getShortMessage() = %v, want %v", got, want) - } -} - -func Test_getShortMessage_withLeadingWhitespace(t *testing.T) { - got := getShortMessage(" message with spaces ") - want := "message with spaces" - if got != want { - t.Errorf("getShortMessage() = %v, want %v", got, want) - } -} diff --git a/vcs/operations_test.go b/vcs/operations_test.go index d032689..fd6781e 100644 --- a/vcs/operations_test.go +++ b/vcs/operations_test.go @@ -52,22 +52,25 @@ func TestCheckBranch_wrongBranch(t *testing.T) { func TestCommitChangelog(t *testing.T) { repoDir := createTestRepo(t) - changelogPath := path.Join(repoDir, "CHANGELOG.md") - err := os.WriteFile(changelogPath, []byte("# Changelog\n"), 0644) + // configure git user so CommitChangelog can create a commit without + // relying on the global git config (which may not exist in CI) + repo, err := git.PlainOpen(repoDir) if err != nil { t.Fatal(err) } - - // stage the file first via worktree - repo, err := git.PlainOpen(repoDir) + cfg, err := repo.Config() if err != nil { t.Fatal(err) } - w, err := repo.Worktree() + cfg.User.Name = "user" + cfg.User.Email = "user@example.com" + err = repo.SetConfig(cfg) if err != nil { t.Fatal(err) } - _, err = w.Add("CHANGELOG.md") + + changelogPath := path.Join(repoDir, "CHANGELOG.md") + err = os.WriteFile(changelogPath, []byte("# Changelog\n"), 0644) if err != nil { t.Fatal(err) }