feat(releaser): initial release v0.4.0
ci / vet, staticcheck, test, build (push) Failing after 3m26s
release / Build and publish release (push) Successful in 4m28s

Complete GitFlow release automation tool for Conventional Commits workflows:

- Core pipeline: branch parsing, tag discovery, commit analysis, version bump
- Maven pom.xml read/write, git commit/tag, HTTPS push with token auth
- GitLab release creation via API with auto-generated release notes
- Configurable via .releaser.yml (tag_prefix, branch_pattern, commit_message, pom_path, gitlab)
- CLI flags: --dry-run, --no-push, --no-commit, --tag-only, --branch, --pom, --tag-prefix, --branch-pattern
- Dockerfile (multi-stage Alpine), .releaser.gitlab-ci.yml reusable template
- Gitea CI (vet + staticcheck + test + build) and release (5-platform cross-compilation) workflows
- Taskfile with build/test/cov/lint/fuzz/ci/docker tasks
- 96% test coverage with real in-memory git repos and fuzz tests for all parsers

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-07-07 00:07:53 +02:00
commit f0723e706a
30 changed files with 3959 additions and 0 deletions
+63
View File
@@ -0,0 +1,63 @@
package branch
import (
"fmt"
"regexp"
"strconv"
)
// DefaultBranchPattern matches "release/X.Y" or "origin/release/X.Y".
// Group 1 = major, group 2 = minor.
const DefaultBranchPattern = `^(?:.*/)?release/(\d+)\.(\d+)$`
// Info holds the major.minor version pinned by the branch name, plus the tag prefix from config.
type Info struct {
Major int
Minor int
TagPrefix string // set from config after Parse (default "v")
}
// Parse extracts major and minor from a branch name using the provided regex pattern.
// The pattern must contain exactly two capture groups: group 1 = major, group 2 = minor.
// TagPrefix is left at its zero value — the caller sets it from config.
func Parse(name, pattern string) (Info, error) {
re, err := regexp.Compile(pattern)
if err != nil {
return Info{}, fmt.Errorf("invalid branch pattern %q: %w", pattern, err)
}
m := re.FindStringSubmatch(name)
if len(m) < 3 {
return Info{}, fmt.Errorf("branch %q does not match pattern %q", name, pattern)
}
major, err := strconv.Atoi(m[1])
if err != nil {
return Info{}, fmt.Errorf("invalid major version in branch %q: %w", name, err)
}
minor, err := strconv.Atoi(m[2])
if err != nil {
return Info{}, fmt.Errorf("invalid minor version in branch %q: %w", name, err)
}
return Info{Major: major, Minor: minor}, nil
}
// MatchesTag checks whether a tag (e.g. "v1.2.3" or "1.2.3") belongs to this branch's version range.
// Uses TagPrefix to match the expected format. Returns the patch number and true on match.
func (i Info) MatchesTag(tag string) (patch int, ok bool) {
prefix := fmt.Sprintf("%s%d.%d.", i.TagPrefix, i.Major, i.Minor)
if len(tag) <= len(prefix) || tag[:len(prefix)] != prefix {
return 0, false
}
p, err := strconv.Atoi(tag[len(prefix):])
if err != nil {
return 0, false
}
return p, true
}
// TagName formats a version string (e.g. "1.2.4") into a full tag name (e.g. "v1.2.4" or "1.2.4").
func (i Info) TagName(version string) string {
return i.TagPrefix + version
}
+136
View File
@@ -0,0 +1,136 @@
package branch
import (
"strings"
"testing"
)
// FuzzParse verifies the parser never panics, and that successful parses produce
// non-negative major/minor values.
func FuzzParse(f *testing.F) {
seeds := []string{
"release/1.2",
"hotfix/10.3",
"origin/release/0.0",
"main",
"release/",
"release/1.2.3",
"",
"\x00",
"release/999999.999999",
strings.Repeat("release/", 100) + "1.2",
}
for _, s := range seeds {
f.Add(s)
}
f.Fuzz(func(t *testing.T, name string) {
info, err := Parse(name, DefaultBranchPattern)
if err != nil {
return // invalid input is expected — just must not panic
}
if info.Major < 0 || info.Minor < 0 {
t.Errorf("Parse(%q) returned negative major/minor: %+v", name, info)
}
})
}
func TestParse(t *testing.T) {
cases := []struct {
input string
pattern string
major int
minor int
err bool
}{
// default pattern
{"release/1.2", DefaultBranchPattern, 1, 2, false},
{"release/10.3", DefaultBranchPattern, 10, 3, false},
{"origin/release/1.2", DefaultBranchPattern, 1, 2, false},
{"refs/heads/release/1.2", DefaultBranchPattern, 1, 2, false},
{"main", DefaultBranchPattern, 0, 0, true},
{"release/1", DefaultBranchPattern, 0, 0, true},
{"release/1.2.3", DefaultBranchPattern, 0, 0, true},
{"feature/1.2", DefaultBranchPattern, 0, 0, true},
// custom pattern — hotfix + release
{"hotfix/2.3", `^(?:.*/)?(?:release|hotfix)/(\d+)\.(\d+)$`, 2, 3, false},
{"release/2.3", `^(?:.*/)?(?:release|hotfix)/(\d+)\.(\d+)$`, 2, 3, false},
{"feature/2.3", `^(?:.*/)?(?:release|hotfix)/(\d+)\.(\d+)$`, 0, 0, true},
// invalid regex
{"release/1.2", `(unclosed`, 0, 0, true},
// valid regex but capture groups match non-digits → Atoi error
{"release/abc.1", `^release/(\w+)\.(\d+)$`, 0, 0, true}, // major non-numeric
{"release/1.abc", `^release/(\d+)\.(\w+)$`, 0, 0, true}, // minor non-numeric
}
for _, c := range cases {
got, err := Parse(c.input, c.pattern)
if c.err {
if err == nil {
t.Errorf("Parse(%q, %q): expected error, got none", c.input, c.pattern)
}
continue
}
if err != nil {
t.Errorf("Parse(%q, %q): unexpected error: %v", c.input, c.pattern, err)
continue
}
if got.Major != c.major || got.Minor != c.minor {
t.Errorf("Parse(%q) = {%d, %d}, want {%d, %d}", c.input, got.Major, got.Minor, c.major, c.minor)
}
}
}
func TestMatchesTag(t *testing.T) {
cases := []struct {
prefix string
tag string
patch int
ok bool
}{
{"v", "v1.2.0", 0, true},
{"v", "v1.2.5", 5, true},
{"v", "v1.2.99", 99, true},
{"v", "v1.3.0", 0, false},
{"v", "v2.2.0", 0, false},
{"v", "1.2.0", 0, false},
{"v", "v1.2.", 0, false},
{"v", "v1.2.x", 0, false},
{"", "1.2.0", 0, true},
{"", "1.2.7", 7, true},
{"", "v1.2.0", 0, false},
}
for _, c := range cases {
info := Info{Major: 1, Minor: 2, TagPrefix: c.prefix}
patch, ok := info.MatchesTag(c.tag)
if ok != c.ok {
t.Errorf("[prefix=%q] MatchesTag(%q): ok=%v, want %v", c.prefix, c.tag, ok, c.ok)
continue
}
if ok && patch != c.patch {
t.Errorf("[prefix=%q] MatchesTag(%q): patch=%d, want %d", c.prefix, c.tag, patch, c.patch)
}
}
}
func TestTagName(t *testing.T) {
cases := []struct {
prefix string
version string
want string
}{
{"v", "1.2.4", "v1.2.4"},
{"", "1.2.4", "1.2.4"},
{"release-", "1.2.4", "release-1.2.4"},
}
for _, c := range cases {
info := Info{Major: 1, Minor: 2, TagPrefix: c.prefix}
if got := info.TagName(c.version); got != c.want {
t.Errorf("TagName(%q) = %q, want %q", c.version, got, c.want)
}
}
}
+63
View File
@@ -0,0 +1,63 @@
package commits
import (
"regexp"
"strings"
)
// Type represents the semantic weight of a commit for versioning purposes.
type Type int
const (
TypeNone Type = iota // no version bump
TypeFix // fix: → patch bump
TypeFeat // feat: → patch bump (minor is pinned to branch)
TypeBreaking // feat!: or BREAKING CHANGE → patch bump
)
// headerRe matches conventional commit headers in non-strict mode:
// case-insensitive, optional scope, optional breaking marker, flexible whitespace around colon.
var headerRe = regexp.MustCompile(`(?i)^(\w+)(?:\([^)]*\))?(!)?\s*:\s*\S`)
// Parse extracts the commit type from a commit message.
// Unparseable messages return TypeNone — never an error.
func Parse(message string) Type {
msg := strings.TrimSpace(message)
// BREAKING CHANGE anywhere in the body/footer takes priority (spec §10).
if strings.Contains(msg, "BREAKING CHANGE") {
return TypeBreaking
}
first := strings.SplitN(msg, "\n", 2)[0]
m := headerRe.FindStringSubmatch(first)
if m == nil {
return TypeNone
}
if m[2] == "!" {
return TypeBreaking
}
switch strings.ToLower(m[1]) {
case "feat":
return TypeFeat
case "fix":
return TypeFix
default:
return TypeNone
}
}
func (t Type) String() string {
switch t {
case TypeFix:
return "fix"
case TypeFeat:
return "feat"
case TypeBreaking:
return "breaking"
default:
return "none"
}
}
+102
View File
@@ -0,0 +1,102 @@
package commits
import (
"strings"
"testing"
)
// FuzzParse verifies that the parser never panics and always returns a valid Type.
func FuzzParse(f *testing.F) {
// Seed corpus: representative conventional commit messages
seeds := []string{
"feat: add OAuth2 login",
"fix: correct null pointer",
"feat!: remove legacy API",
"feat(auth): add login\n\nBREAKING CHANGE: old endpoint removed",
"chore: update deps",
"Feat:missing space",
"FIX(scope)!: mixed case breaking",
"",
"\x00",
"BREAKING CHANGE: bare footer",
"not a conventional commit at all",
"feat: \n\n body only",
strings.Repeat("a", 4096),
}
for _, s := range seeds {
f.Add(s)
}
f.Fuzz(func(t *testing.T, msg string) {
result := Parse(msg)
if result < TypeNone || result > TypeBreaking {
t.Errorf("Parse(%q) returned out-of-range type %d", msg, result)
}
})
}
func TestTypeString(t *testing.T) {
cases := []struct {
t Type
want string
}{
{TypeNone, "none"},
{TypeFix, "fix"},
{TypeFeat, "feat"},
{TypeBreaking, "breaking"},
{Type(99), "none"}, // unknown → default
}
for _, c := range cases {
if got := c.t.String(); got != c.want {
t.Errorf("Type(%d).String() = %q, want %q", c.t, got, c.want)
}
}
}
func TestParse(t *testing.T) {
cases := []struct {
msg string
want Type
}{
// standard cases
{"feat: add login page", TypeFeat},
{"fix: correct null pointer", TypeFix},
{"chore: update deps", TypeNone},
{"docs: update README", TypeNone},
// non-strict: case-insensitive
{"Feat: add login page", TypeFeat},
{"FIX: correct null pointer", TypeFix},
{"FEAT: add login", TypeFeat},
// non-strict: missing space after colon
{"feat:add login", TypeFeat},
{"fix:correct null pointer", TypeFix},
// with scope
{"feat(auth): add OAuth2", TypeFeat},
{"fix(db): prevent deadlock", TypeFix},
// breaking change via !
{"feat!: remove legacy API", TypeBreaking},
{"fix!: change response format", TypeBreaking},
{"feat(api)!: rename endpoint", TypeBreaking},
// breaking change via footer
{"feat: new API\n\nBREAKING CHANGE: old API removed", TypeBreaking},
{"refactor: cleanup\n\nBREAKING CHANGE: interface changed", TypeBreaking},
// unparseable — silently ignored
{"WIP", TypeNone},
{"Merge branch 'main' into release/1.2", TypeNone},
{"", TypeNone},
{"fix a bug without colon", TypeNone},
}
for _, c := range cases {
got := Parse(c.msg)
if got != c.want {
t.Errorf("Parse(%q) = %s, want %s", c.msg, got, c.want)
}
}
}
+91
View File
@@ -0,0 +1,91 @@
package config
import (
"errors"
"fmt"
"os"
"path/filepath"
"gopkg.in/yaml.v3"
"git.k3nny.fr/releaser/internal/branch"
)
const filename = ".releaser.yml"
type Config struct {
Git GitConfig `yaml:"git"`
Maven MavenConfig `yaml:"maven"`
GitLab GitLabConfig `yaml:"gitlab"`
}
type GitConfig struct {
TagPrefix string `yaml:"tag_prefix"`
BranchPattern string `yaml:"branch_pattern"`
CommitMessage string `yaml:"commit_message"`
AuthorName string `yaml:"author_name"`
AuthorEmail string `yaml:"author_email"`
}
type MavenConfig struct {
PomPath string `yaml:"pom_path"`
}
type GitLabConfig struct {
URL string `yaml:"url"`
Token string `yaml:"token"`
Project string `yaml:"project"`
}
func defaults() Config {
return Config{
Git: GitConfig{
TagPrefix: "v",
BranchPattern: branch.DefaultBranchPattern,
CommitMessage: "chore(release): {version} [skip ci]",
},
Maven: MavenConfig{
PomPath: "pom.xml",
},
}
}
// Load reads .releaser.yml from dir and merges it over the defaults.
// Missing file is not an error — defaults are returned as-is.
func Load(dir string) (Config, error) {
cfg := defaults()
data, err := os.ReadFile(filepath.Join(dir, filename))
if errors.Is(err, os.ErrNotExist) {
return cfg, nil
}
if err != nil {
return cfg, fmt.Errorf("read %s: %w", filename, err)
}
if err := yaml.Unmarshal(data, &cfg); err != nil {
return cfg, fmt.Errorf("parse %s: %w", filename, err)
}
return cfg, nil
}
// ApplyEnv fills empty GitLab fields from the standard GitLab CI environment variables.
// Values already set in the config file are never overwritten.
func (c *Config) ApplyEnv() {
if c.GitLab.Token == "" {
c.GitLab.Token = os.Getenv("GITLAB_TOKEN")
}
if c.GitLab.URL == "" {
// CI_SERVER_URL is the cleanest source ("https://gitlab.example.com")
c.GitLab.URL = os.Getenv("CI_SERVER_URL")
}
if c.GitLab.Project == "" {
// Prefer numeric ID; fall back to namespace/project path
if id := os.Getenv("CI_PROJECT_ID"); id != "" {
c.GitLab.Project = id
} else {
c.GitLab.Project = os.Getenv("CI_PROJECT_PATH")
}
}
}
+144
View File
@@ -0,0 +1,144 @@
package config
import (
"os"
"path/filepath"
"testing"
)
func TestLoadDefaults(t *testing.T) {
cfg, err := Load(t.TempDir())
if err != nil {
t.Fatal(err)
}
if cfg.Git.TagPrefix != "v" {
t.Errorf("TagPrefix = %q, want %q", cfg.Git.TagPrefix, "v")
}
if cfg.Maven.PomPath != "pom.xml" {
t.Errorf("PomPath = %q, want %q", cfg.Maven.PomPath, "pom.xml")
}
}
func TestLoadOverride(t *testing.T) {
dir := t.TempDir()
content := `
git:
tag_prefix: ""
commit_message: "release: {version}"
maven:
pom_path: "services/api/pom.xml"
`
if err := os.WriteFile(filepath.Join(dir, filename), []byte(content), 0644); err != nil {
t.Fatal(err)
}
cfg, err := Load(dir)
if err != nil {
t.Fatal(err)
}
if cfg.Git.TagPrefix != "" {
t.Errorf("TagPrefix = %q, want %q", cfg.Git.TagPrefix, "")
}
if cfg.Git.CommitMessage != "release: {version}" {
t.Errorf("CommitMessage = %q", cfg.Git.CommitMessage)
}
if cfg.Maven.PomPath != "services/api/pom.xml" {
t.Errorf("PomPath = %q", cfg.Maven.PomPath)
}
}
func TestLoadReadError(t *testing.T) {
dir := t.TempDir()
// Create a directory named .releaser.yml so os.ReadFile fails (is a directory)
if err := os.Mkdir(filepath.Join(dir, filename), 0755); err != nil {
t.Fatal(err)
}
_, err := Load(dir)
if err == nil {
t.Error("expected error when .releaser.yml is a directory")
}
}
func TestLoadInvalidYAML(t *testing.T) {
dir := t.TempDir()
if err := os.WriteFile(filepath.Join(dir, filename), []byte("{[invalid yaml"), 0644); err != nil {
t.Fatal(err)
}
_, err := Load(dir)
if err == nil {
t.Error("expected error for invalid YAML")
}
}
func TestApplyEnv(t *testing.T) {
t.Setenv("GITLAB_TOKEN", "mytoken")
t.Setenv("CI_SERVER_URL", "https://gitlab.example.com")
t.Setenv("CI_PROJECT_ID", "42")
cfg := defaults()
cfg.ApplyEnv()
if cfg.GitLab.Token != "mytoken" {
t.Errorf("Token = %q, want mytoken", cfg.GitLab.Token)
}
if cfg.GitLab.URL != "https://gitlab.example.com" {
t.Errorf("URL = %q", cfg.GitLab.URL)
}
if cfg.GitLab.Project != "42" {
t.Errorf("Project = %q, want 42", cfg.GitLab.Project)
}
}
func TestApplyEnvDoesNotOverwrite(t *testing.T) {
t.Setenv("GITLAB_TOKEN", "env-token")
t.Setenv("CI_SERVER_URL", "https://env.example.com")
t.Setenv("CI_PROJECT_ID", "99")
cfg := defaults()
cfg.GitLab.Token = "config-token"
cfg.GitLab.URL = "https://config.example.com"
cfg.GitLab.Project = "config-project"
cfg.ApplyEnv()
if cfg.GitLab.Token != "config-token" {
t.Errorf("Token overwritten: got %q", cfg.GitLab.Token)
}
if cfg.GitLab.URL != "https://config.example.com" {
t.Errorf("URL overwritten: got %q", cfg.GitLab.URL)
}
if cfg.GitLab.Project != "config-project" {
t.Errorf("Project overwritten: got %q", cfg.GitLab.Project)
}
}
func TestApplyEnvProjectPathFallback(t *testing.T) {
t.Setenv("CI_PROJECT_ID", "")
t.Setenv("CI_PROJECT_PATH", "mygroup/myapp")
cfg := defaults()
cfg.ApplyEnv()
if cfg.GitLab.Project != "mygroup/myapp" {
t.Errorf("Project = %q, want mygroup/myapp (from CI_PROJECT_PATH)", cfg.GitLab.Project)
}
}
func TestLoadPartialOverride(t *testing.T) {
dir := t.TempDir()
// Only override tag_prefix — commit_message should keep its default
content := "git:\n tag_prefix: \"\"\n"
if err := os.WriteFile(filepath.Join(dir, filename), []byte(content), 0644); err != nil {
t.Fatal(err)
}
cfg, err := Load(dir)
if err != nil {
t.Fatal(err)
}
if cfg.Git.TagPrefix != "" {
t.Errorf("TagPrefix = %q, want %q", cfg.Git.TagPrefix, "")
}
if cfg.Git.CommitMessage != defaults().Git.CommitMessage {
t.Errorf("CommitMessage = %q, want default", cfg.Git.CommitMessage)
}
}
+280
View File
@@ -0,0 +1,280 @@
package gitutil
import (
"errors"
"fmt"
"sort"
"time"
gogit "github.com/go-git/go-git/v5"
gitconfig "github.com/go-git/go-git/v5/config"
"github.com/go-git/go-git/v5/plumbing"
"github.com/go-git/go-git/v5/plumbing/object"
githttp "github.com/go-git/go-git/v5/plumbing/transport/http"
"github.com/go-git/go-git/v5/plumbing/storer"
"git.k3nny.fr/releaser/internal/branch"
)
// IsWorkingTreeClean returns true when there are no staged or modified tracked files.
// Untracked files are ignored so that unreleased work-in-progress does not block a release.
func IsWorkingTreeClean(repo *gogit.Repository) (bool, error) {
w, err := repo.Worktree()
if err != nil {
return false, err
}
status, err := w.Status()
if err != nil {
return false, err
}
for _, s := range status {
if s.Staging != gogit.Unmodified && s.Staging != gogit.Untracked {
return false, nil
}
if s.Worktree != gogit.Unmodified && s.Worktree != gogit.Untracked {
return false, nil
}
}
return true, nil
}
// CurrentBranch returns the short branch name from HEAD.
// Returns an error when HEAD is detached (common in CI pipelines).
func CurrentBranch(repo *gogit.Repository) (string, error) {
head, err := repo.Head()
if err != nil {
return "", fmt.Errorf("read HEAD: %w", err)
}
if !head.Name().IsBranch() {
return "", fmt.Errorf("HEAD is detached — use --branch to specify the release branch")
}
return head.Name().Short(), nil
}
type tagCandidate struct {
name string
patch int
}
// LatestTag returns the highest-patch tag matching the branch's version range that is
// reachable from HEAD. Returns ("", -1, nil) when no matching ancestor tag exists.
func LatestTag(repo *gogit.Repository, info branch.Info) (string, int, error) {
head, err := repo.Head()
if err != nil {
return "", -1, err
}
headCommit, err := repo.CommitObject(head.Hash())
if err != nil {
return "", -1, err
}
refs, err := repo.Tags()
if err != nil {
return "", -1, err
}
var candidates []tagCandidate
err = refs.ForEach(func(ref *plumbing.Reference) error {
name := ref.Name().Short()
patch, ok := info.MatchesTag(name)
if !ok {
return nil
}
commitHash, err := resolveTagToCommit(repo, ref)
if err != nil {
return nil // silently skip malformed tags
}
tagCommit, err := repo.CommitObject(commitHash)
if err != nil {
return nil
}
if tagCommit.Hash == headCommit.Hash {
candidates = append(candidates, tagCandidate{name, patch})
return nil
}
anc, err := tagCommit.IsAncestor(headCommit)
if err != nil || !anc {
return nil
}
candidates = append(candidates, tagCandidate{name, patch})
return nil
})
if err != nil {
return "", -1, err
}
if len(candidates) == 0 {
return "", -1, nil
}
sort.Slice(candidates, func(i, j int) bool {
return candidates[i].patch > candidates[j].patch
})
best := candidates[0]
return best.name, best.patch, nil
}
// CommitsSince returns commit messages from HEAD down to (but not including) the tagged commit.
func CommitsSince(repo *gogit.Repository, tagName string) ([]string, error) {
ref, err := repo.Tag(tagName)
if err != nil {
return nil, fmt.Errorf("resolve tag %q: %w", tagName, err)
}
tagHash, err := resolveTagToCommit(repo, ref)
if err != nil {
return nil, fmt.Errorf("resolve tag commit: %w", err)
}
head, err := repo.Head()
if err != nil {
return nil, err
}
iter, err := repo.Log(&gogit.LogOptions{From: head.Hash()})
if err != nil {
return nil, err
}
var messages []string
err = iter.ForEach(func(c *object.Commit) error {
if c.Hash == tagHash {
return storer.ErrStop
}
messages = append(messages, c.Message)
return nil
})
return messages, err
}
// AllCommits returns all commit messages reachable from HEAD.
func AllCommits(repo *gogit.Repository) ([]string, error) {
head, err := repo.Head()
if err != nil {
return nil, err
}
iter, err := repo.Log(&gogit.LogOptions{From: head.Hash()})
if err != nil {
return nil, err
}
var messages []string
err = iter.ForEach(func(c *object.Commit) error {
messages = append(messages, c.Message)
return nil
})
return messages, err
}
// AuthorFromConfig reads user.name and user.email from the repository's git config.
// Local config overrides global; returns empty strings if neither is set.
func AuthorFromConfig(repo *gogit.Repository) (name, email string) {
cfg, err := repo.ConfigScoped(gitconfig.GlobalScope)
if err == nil {
name = cfg.User.Name
email = cfg.User.Email
}
// Local config overrides global
local, err := repo.Config()
if err == nil {
if local.User.Name != "" {
name = local.User.Name
}
if local.User.Email != "" {
email = local.User.Email
}
}
return
}
// CommitFile stages filePath (relative to worktree root) and creates a commit.
func CommitFile(repo *gogit.Repository, filePath, message, authorName, authorEmail string) (plumbing.Hash, error) {
w, err := repo.Worktree()
if err != nil {
return plumbing.ZeroHash, err
}
if _, err := w.Add(filePath); err != nil {
return plumbing.ZeroHash, fmt.Errorf("git add %s: %w", filePath, err)
}
hash, err := w.Commit(message, &gogit.CommitOptions{
Author: &object.Signature{
Name: authorName,
Email: authorEmail,
When: time.Now(),
},
})
if err != nil {
return plumbing.ZeroHash, fmt.Errorf("git commit: %w", err)
}
return hash, nil
}
// CreateTag creates a lightweight tag on HEAD.
func CreateTag(repo *gogit.Repository, tagName string) error {
head, err := repo.Head()
if err != nil {
return err
}
if _, err := repo.CreateTag(tagName, head.Hash(), nil); err != nil {
return fmt.Errorf("create tag %s: %w", tagName, err)
}
return nil
}
// Push pushes the given branch and tag to the "origin" remote.
// If token is non-empty, HTTPS basic auth (oauth2/token) is used.
// Passing an empty token lets go-git use the system credential helper or SSH agent.
func Push(repo *gogit.Repository, branchName, tagName, token string) error {
remote, err := repo.Remote("origin")
if err != nil {
return fmt.Errorf("remote origin not found: %w", err)
}
opts := &gogit.PushOptions{
RefSpecs: []gitconfig.RefSpec{
gitconfig.RefSpec(fmt.Sprintf("refs/heads/%s:refs/heads/%s", branchName, branchName)),
gitconfig.RefSpec(fmt.Sprintf("refs/tags/%s:refs/tags/%s", tagName, tagName)),
},
}
if token != "" {
opts.Auth = &githttp.BasicAuth{
Username: "oauth2",
Password: token,
}
}
if err := remote.Push(opts); err != nil && !errors.Is(err, gogit.NoErrAlreadyUpToDate) {
return fmt.Errorf("git push: %w", err)
}
return nil
}
// resolveTagToCommit follows tag objects until it reaches a commit.
// Handles both lightweight tags (ref → commit) and annotated tags (ref → tag object → … → commit).
func resolveTagToCommit(repo *gogit.Repository, ref *plumbing.Reference) (plumbing.Hash, error) {
hash := ref.Hash()
for {
obj, err := repo.Object(plumbing.AnyObject, hash)
if err != nil {
return plumbing.ZeroHash, err
}
switch o := obj.(type) {
case *object.Commit:
return o.Hash, nil
case *object.Tag:
hash = o.Target
default:
return plumbing.ZeroHash, fmt.Errorf("unexpected object type %s at %s", obj.Type(), hash)
}
}
}
+701
View File
@@ -0,0 +1,701 @@
package gitutil
import (
"os"
"path/filepath"
"strings"
"testing"
"time"
gogit "github.com/go-git/go-git/v5"
gitconfig "github.com/go-git/go-git/v5/config"
"github.com/go-git/go-git/v5/plumbing"
"github.com/go-git/go-git/v5/plumbing/object"
"git.k3nny.fr/releaser/internal/branch"
)
// ── helpers ──────────────────────────────────────────────────────────────────
func newTestRepo(t *testing.T) (*gogit.Repository, string) {
t.Helper()
dir := t.TempDir()
repo, err := gogit.PlainInit(dir, false)
if err != nil {
t.Fatalf("init repo: %v", err)
}
return repo, dir
}
func testSig() *object.Signature {
return &object.Signature{Name: "Test", Email: "test@example.com", When: time.Now()}
}
// addCommit writes content to test.txt, stages it, and creates a commit.
func addCommit(t *testing.T, repo *gogit.Repository, dir, message, content string) plumbing.Hash {
t.Helper()
if err := os.WriteFile(filepath.Join(dir, "test.txt"), []byte(content), 0644); err != nil {
t.Fatal(err)
}
w, err := repo.Worktree()
if err != nil {
t.Fatal(err)
}
if _, err := w.Add("test.txt"); err != nil {
t.Fatal(err)
}
hash, err := w.Commit(message, &gogit.CommitOptions{Author: testSig()})
if err != nil {
t.Fatalf("commit %q: %v", message, err)
}
return hash
}
func addTag(t *testing.T, repo *gogit.Repository, name string) {
t.Helper()
head, err := repo.Head()
if err != nil {
t.Fatal(err)
}
if _, err := repo.CreateTag(name, head.Hash(), nil); err != nil {
t.Fatalf("create lightweight tag %s: %v", name, err)
}
}
func addAnnotatedTag(t *testing.T, repo *gogit.Repository, name string) {
t.Helper()
head, err := repo.Head()
if err != nil {
t.Fatal(err)
}
_, err = repo.CreateTag(name, head.Hash(), &gogit.CreateTagOptions{
Tagger: testSig(),
Message: "release " + name,
})
if err != nil {
t.Fatalf("create annotated tag %s: %v", name, err)
}
}
// ── IsWorkingTreeClean ────────────────────────────────────────────────────────
func TestIsWorkingTreeClean(t *testing.T) {
t.Run("clean after commit", func(t *testing.T) {
repo, dir := newTestRepo(t)
addCommit(t, repo, dir, "chore: init", "initial")
clean, err := IsWorkingTreeClean(repo)
if err != nil {
t.Fatal(err)
}
if !clean {
t.Error("expected clean working tree after commit")
}
})
t.Run("dirty when tracked file is modified", func(t *testing.T) {
repo, dir := newTestRepo(t)
addCommit(t, repo, dir, "chore: init", "initial")
os.WriteFile(filepath.Join(dir, "test.txt"), []byte("modified"), 0644)
clean, err := IsWorkingTreeClean(repo)
if err != nil {
t.Fatal(err)
}
if clean {
t.Error("expected dirty working tree after modification")
}
})
t.Run("dirty when new file is staged", func(t *testing.T) {
repo, dir := newTestRepo(t)
addCommit(t, repo, dir, "chore: init", "initial")
os.WriteFile(filepath.Join(dir, "staged.txt"), []byte("new"), 0644)
w, _ := repo.Worktree()
w.Add("staged.txt")
clean, err := IsWorkingTreeClean(repo)
if err != nil {
t.Fatal(err)
}
if clean {
t.Error("expected dirty working tree with staged file")
}
})
t.Run("untracked file does not dirty the tree", func(t *testing.T) {
repo, dir := newTestRepo(t)
addCommit(t, repo, dir, "chore: init", "initial")
os.WriteFile(filepath.Join(dir, "untracked.txt"), []byte("new"), 0644)
clean, err := IsWorkingTreeClean(repo)
if err != nil {
t.Fatal(err)
}
if !clean {
t.Error("untracked file should not dirty the working tree")
}
})
}
// ── CurrentBranch ─────────────────────────────────────────────────────────────
func TestCurrentBranch(t *testing.T) {
repo, dir := newTestRepo(t)
addCommit(t, repo, dir, "chore: init", "initial")
name, err := CurrentBranch(repo)
if err != nil {
t.Fatal(err)
}
if name == "" {
t.Error("branch name should not be empty")
}
}
func TestCurrentBranchDetached(t *testing.T) {
repo, dir := newTestRepo(t)
addCommit(t, repo, dir, "chore: init", "initial")
// Detach HEAD by replacing the symbolic ref with a hash ref
head, _ := repo.Head()
detached := plumbing.NewHashReference(plumbing.HEAD, head.Hash())
if err := repo.Storer.SetReference(detached); err != nil {
t.Fatal(err)
}
_, err := CurrentBranch(repo)
if err == nil {
t.Error("expected error for detached HEAD")
}
}
// ── LatestTag ─────────────────────────────────────────────────────────────────
func TestLatestTagNoTags(t *testing.T) {
repo, dir := newTestRepo(t)
addCommit(t, repo, dir, "fix: first", "v1")
info := branch.Info{Major: 1, Minor: 2, TagPrefix: "v"}
tag, patch, err := LatestTag(repo, info)
if err != nil {
t.Fatal(err)
}
if tag != "" || patch != -1 {
t.Errorf("expected no tag: got %q patch=%d", tag, patch)
}
}
func TestLatestTagSingle(t *testing.T) {
repo, dir := newTestRepo(t)
addCommit(t, repo, dir, "fix: first", "v1")
addTag(t, repo, "v1.2.0")
addCommit(t, repo, dir, "fix: second", "v2")
info := branch.Info{Major: 1, Minor: 2, TagPrefix: "v"}
tag, patch, err := LatestTag(repo, info)
if err != nil {
t.Fatal(err)
}
if tag != "v1.2.0" || patch != 0 {
t.Errorf("got tag=%q patch=%d, want v1.2.0 patch=0", tag, patch)
}
}
func TestLatestTagPicksHighestPatch(t *testing.T) {
repo, dir := newTestRepo(t)
addCommit(t, repo, dir, "fix: c1", "v1")
addTag(t, repo, "v1.2.0")
addCommit(t, repo, dir, "fix: c2", "v2")
addTag(t, repo, "v1.2.1")
addCommit(t, repo, dir, "feat: c3", "v3")
addTag(t, repo, "v1.2.5")
addCommit(t, repo, dir, "fix: c4", "v4") // HEAD after the last tag
info := branch.Info{Major: 1, Minor: 2, TagPrefix: "v"}
tag, patch, err := LatestTag(repo, info)
if err != nil {
t.Fatal(err)
}
if tag != "v1.2.5" || patch != 5 {
t.Errorf("got tag=%q patch=%d, want v1.2.5 patch=5", tag, patch)
}
}
func TestLatestTagCrossVersionIgnored(t *testing.T) {
repo, dir := newTestRepo(t)
addCommit(t, repo, dir, "fix: c1", "v1")
addTag(t, repo, "v1.2.0") // matching
addCommit(t, repo, dir, "fix: c2", "v2")
addTag(t, repo, "v1.3.0") // different minor — must be ignored
addCommit(t, repo, dir, "fix: c3", "v3")
info := branch.Info{Major: 1, Minor: 2, TagPrefix: "v"}
tag, patch, err := LatestTag(repo, info)
if err != nil {
t.Fatal(err)
}
if tag != "v1.2.0" || patch != 0 {
t.Errorf("got tag=%q patch=%d, want v1.2.0 patch=0 (v1.3.0 must be ignored)", tag, patch)
}
}
func TestLatestTagNoPrefixVariant(t *testing.T) {
repo, dir := newTestRepo(t)
addCommit(t, repo, dir, "fix: c1", "v1")
addTag(t, repo, "2.0.0") // no "v" prefix
addCommit(t, repo, dir, "fix: c2", "v2")
info := branch.Info{Major: 2, Minor: 0, TagPrefix: ""}
tag, patch, err := LatestTag(repo, info)
if err != nil {
t.Fatal(err)
}
if tag != "2.0.0" || patch != 0 {
t.Errorf("got tag=%q patch=%d, want 2.0.0 patch=0", tag, patch)
}
}
func TestLatestTagAnnotated(t *testing.T) {
repo, dir := newTestRepo(t)
addCommit(t, repo, dir, "fix: c1", "v1")
addAnnotatedTag(t, repo, "v1.2.0")
addCommit(t, repo, dir, "fix: c2", "v2")
info := branch.Info{Major: 1, Minor: 2, TagPrefix: "v"}
tag, patch, err := LatestTag(repo, info)
if err != nil {
t.Fatal(err)
}
if tag != "v1.2.0" || patch != 0 {
t.Errorf("annotated tag: got %q patch=%d, want v1.2.0", tag, patch)
}
}
// ── CommitsSince ──────────────────────────────────────────────────────────────
func TestCommitsSince(t *testing.T) {
repo, dir := newTestRepo(t)
addCommit(t, repo, dir, "chore: baseline", "v0")
addTag(t, repo, "v1.2.0")
addCommit(t, repo, dir, "fix: bug fix", "v1")
addCommit(t, repo, dir, "feat: new feature", "v2")
msgs, err := CommitsSince(repo, "v1.2.0")
if err != nil {
t.Fatal(err)
}
if len(msgs) != 2 {
t.Fatalf("expected 2 commits since v1.2.0, got %d: %v", len(msgs), msgs)
}
// Log is reverse-chronological: newest first
if !strings.Contains(msgs[0], "feat: new feature") {
t.Errorf("msgs[0] = %q, want feat: new feature", msgs[0])
}
if !strings.Contains(msgs[1], "fix: bug fix") {
t.Errorf("msgs[1] = %q, want fix: bug fix", msgs[1])
}
}
func TestCommitsSinceTagOnHead(t *testing.T) {
repo, dir := newTestRepo(t)
addCommit(t, repo, dir, "fix: something", "v1")
addTag(t, repo, "v1.2.0")
// HEAD IS the tag — no commits since
msgs, err := CommitsSince(repo, "v1.2.0")
if err != nil {
t.Fatal(err)
}
if len(msgs) != 0 {
t.Errorf("expected 0 commits since tag on HEAD, got %d", len(msgs))
}
}
// ── AllCommits ────────────────────────────────────────────────────────────────
func TestAllCommits(t *testing.T) {
repo, dir := newTestRepo(t)
addCommit(t, repo, dir, "fix: c1", "v1")
addCommit(t, repo, dir, "fix: c2", "v2")
addCommit(t, repo, dir, "fix: c3", "v3")
msgs, err := AllCommits(repo)
if err != nil {
t.Fatal(err)
}
if len(msgs) != 3 {
t.Errorf("expected 3 commits, got %d", len(msgs))
}
}
// ── CommitFile ────────────────────────────────────────────────────────────────
func TestCommitFile(t *testing.T) {
repo, dir := newTestRepo(t)
addCommit(t, repo, dir, "chore: initial", "init")
pomPath := filepath.Join(dir, "pom.xml")
if err := os.WriteFile(pomPath, []byte("<project><version>1.2.4</version></project>"), 0644); err != nil {
t.Fatal(err)
}
hash, err := CommitFile(repo, "pom.xml", "chore(release): v1.2.4 [skip ci]", "Releaser", "ci@example.com")
if err != nil {
t.Fatal(err)
}
if hash.IsZero() {
t.Error("expected non-zero commit hash")
}
commit, err := repo.CommitObject(hash)
if err != nil {
t.Fatal(err)
}
if commit.Message != "chore(release): v1.2.4 [skip ci]" {
t.Errorf("commit message = %q", commit.Message)
}
if commit.Author.Name != "Releaser" {
t.Errorf("author name = %q, want Releaser", commit.Author.Name)
}
if commit.Author.Email != "ci@example.com" {
t.Errorf("author email = %q", commit.Author.Email)
}
}
// ── CreateTag ─────────────────────────────────────────────────────────────────
func TestCreateTag(t *testing.T) {
repo, dir := newTestRepo(t)
addCommit(t, repo, dir, "fix: something", "v1")
if err := CreateTag(repo, "v1.2.0"); err != nil {
t.Fatal(err)
}
ref, err := repo.Tag("v1.2.0")
if err != nil {
t.Fatalf("tag v1.2.0 not found after creation: %v", err)
}
head, _ := repo.Head()
if ref.Hash() != head.Hash() {
t.Error("lightweight tag does not point to HEAD")
}
}
func TestCreateTagDuplicate(t *testing.T) {
repo, dir := newTestRepo(t)
addCommit(t, repo, dir, "fix: something", "v1")
addTag(t, repo, "v1.2.0")
if err := CreateTag(repo, "v1.2.0"); err == nil {
t.Error("expected error when creating duplicate tag")
}
}
// ── Push (error path only — no real remote needed) ───────────────────────────
func TestPushRemoteFails(t *testing.T) {
repo, dir := newTestRepo(t)
addCommit(t, repo, dir, "fix: something", "v1")
addTag(t, repo, "v1.2.0")
// Remote config exists but points nowhere → Push will error on remote.Push
if _, err := repo.CreateRemote(&gitconfig.RemoteConfig{
Name: "origin",
URLs: []string{"/nonexistent/bare/repo"},
}); err != nil {
t.Fatal(err)
}
err := Push(repo, "master", "v1.2.0", "")
if err == nil {
t.Error("expected push error for invalid remote path")
}
}
func TestPushNoRemote(t *testing.T) {
repo, dir := newTestRepo(t)
addCommit(t, repo, dir, "fix: something", "v1")
err := Push(repo, "master", "v1.0.0", "")
if err == nil {
t.Error("expected error when no remote is configured")
}
}
// ── bare-repo error paths ─────────────────────────────────────────────────────
func TestIsWorkingTreeCleanBareRepo(t *testing.T) {
dir := t.TempDir()
repo, err := gogit.PlainInit(dir, true) // bare = true
if err != nil {
t.Fatal(err)
}
_, err = IsWorkingTreeClean(repo)
if err == nil {
t.Error("expected error for bare repo (no worktree)")
}
}
func TestCurrentBranchEmptyRepo(t *testing.T) {
// Fresh repo with no commits — HEAD symbolic ref points to refs/heads/master
// but that ref doesn't exist yet, so Head() returns plumbing.ErrReferenceNotFound.
repo, _ := newTestRepo(t)
_, err := CurrentBranch(repo)
if err == nil {
t.Error("expected error for empty repo (no HEAD commit)")
}
}
func TestAllCommitsEmptyRepo(t *testing.T) {
repo, _ := newTestRepo(t)
_, err := AllCommits(repo)
if err == nil {
t.Error("expected error for empty repo")
}
}
func TestCreateTagEmptyRepo(t *testing.T) {
repo, _ := newTestRepo(t)
err := CreateTag(repo, "v1.0.0")
if err == nil {
t.Error("expected error when creating tag on empty repo")
}
}
func TestLatestTagEmptyRepo(t *testing.T) {
repo, _ := newTestRepo(t)
_, _, err := LatestTag(repo, branch.Info{Major: 1, Minor: 2, TagPrefix: "v"})
if err == nil {
t.Error("expected error for empty repo")
}
}
func TestCommitFileBareRepo(t *testing.T) {
dir := t.TempDir()
repo, err := gogit.PlainInit(dir, true)
if err != nil {
t.Fatal(err)
}
_, err = CommitFile(repo, "pom.xml", "chore: test", "Test", "t@t.com")
if err == nil {
t.Error("expected error for bare repo (no worktree)")
}
}
func TestCommitFileNonexistentFile(t *testing.T) {
repo, dir := newTestRepo(t)
addCommit(t, repo, dir, "chore: init", "init")
// Try to stage a file that doesn't exist in the worktree
_, err := CommitFile(repo, "nonexistent.xml", "chore: bad", "Test", "t@t.com")
if err == nil {
t.Error("expected error when staging nonexistent file")
}
}
// ── LatestTag edge cases ──────────────────────────────────────────────────────
func TestLatestTagOnHead(t *testing.T) {
repo, dir := newTestRepo(t)
addCommit(t, repo, dir, "fix: c1", "v1")
addTag(t, repo, "v1.2.0") // tag is ON HEAD, not behind HEAD
info := branch.Info{Major: 1, Minor: 2, TagPrefix: "v"}
tag, patch, err := LatestTag(repo, info)
if err != nil {
t.Fatal(err)
}
// Tag at HEAD should still be found (it is the current release base)
if tag != "v1.2.0" || patch != 0 {
t.Errorf("got %q patch=%d, want v1.2.0 patch=0 when tag is on HEAD", tag, patch)
}
}
func TestLatestTagNonAncestorIgnored(t *testing.T) {
repo, dir := newTestRepo(t)
baseHash := addCommit(t, repo, dir, "chore: base", "base")
// Create sibling branch from base commit and put a v1.2.0 tag on it
w, _ := repo.Worktree()
if err := w.Checkout(&gogit.CheckoutOptions{
Branch: plumbing.NewBranchReferenceName("sibling"),
Hash: baseHash,
Create: true,
}); err != nil {
t.Fatal(err)
}
addCommit(t, repo, dir, "feat: sibling work", "sibling")
addTag(t, repo, "v1.2.0")
// Switch back to master and add a commit (diverges from sibling)
if err := w.Checkout(&gogit.CheckoutOptions{
Branch: plumbing.NewBranchReferenceName("master"),
}); err != nil {
t.Fatal(err)
}
addCommit(t, repo, dir, "fix: mainline only", "mainline")
info := branch.Info{Major: 1, Minor: 2, TagPrefix: "v"}
tag, patch, err := LatestTag(repo, info)
if err != nil {
t.Fatal(err)
}
// v1.2.0 is on the sibling branch — not an ancestor of current HEAD
if tag != "" || patch != -1 {
t.Errorf("non-ancestor tag should be ignored: got %q patch=%d", tag, patch)
}
}
func TestLatestTagSkipsMalformedRef(t *testing.T) {
repo, dir := newTestRepo(t)
addCommit(t, repo, dir, "fix: c1", "v1")
// Create a v1.2.0 tag reference pointing to a hash that doesn't exist.
// LatestTag must silently skip it instead of returning an error.
fakeHash := plumbing.NewHash("deadbeefdeadbeefdeadbeefdeadbeefdeadbeef")
fakeRef := plumbing.NewHashReference(plumbing.NewTagReferenceName("v1.2.0"), fakeHash)
if err := repo.Storer.SetReference(fakeRef); err != nil {
t.Fatal(err)
}
info := branch.Info{Major: 1, Minor: 2, TagPrefix: "v"}
tag, patch, err := LatestTag(repo, info)
if err != nil {
t.Fatalf("LatestTag should not error on malformed tag ref: %v", err)
}
if tag != "" || patch != -1 {
t.Errorf("malformed tag should be skipped: got %q patch=%d", tag, patch)
}
}
// ── resolveTagToCommit default case ──────────────────────────────────────────
func TestResolveTagToCommitBlobRef(t *testing.T) {
repo, dir := newTestRepo(t)
h := addCommit(t, repo, dir, "fix: c1", "content")
// Obtain a blob hash from the commit tree to use as an adversarial ref target.
commit, err := repo.CommitObject(h)
if err != nil {
t.Fatal(err)
}
tree, err := commit.Tree()
if err != nil || len(tree.Entries) == 0 {
t.Fatal("need at least one tree entry")
}
blobHash := tree.Entries[0].Hash
blobRef := plumbing.NewHashReference(plumbing.NewTagReferenceName("blob-tag"), blobHash)
_, err = resolveTagToCommit(repo, blobRef)
if err == nil {
t.Error("expected error when tag points to a blob (not a commit or tag object)")
}
}
// ── CommitsSince error paths ──────────────────────────────────────────────────
func TestCommitsSinceBadTag(t *testing.T) {
repo, dir := newTestRepo(t)
addCommit(t, repo, dir, "fix: something", "v1")
_, err := CommitsSince(repo, "v-does-not-exist")
if err == nil {
t.Error("expected error for nonexistent tag")
}
}
func TestCommitsSinceMalformedTagRef(t *testing.T) {
repo, dir := newTestRepo(t)
addCommit(t, repo, dir, "fix: c1", "v1")
// Create a tag that references a nonexistent hash so resolveTagToCommit fails.
fakeHash := plumbing.NewHash("cafebabecafebabecafebabecafebabecafebabe")
fakeRef := plumbing.NewHashReference(plumbing.NewTagReferenceName("v1.2.0"), fakeHash)
if err := repo.Storer.SetReference(fakeRef); err != nil {
t.Fatal(err)
}
_, err := CommitsSince(repo, "v1.2.0")
if err == nil {
t.Error("expected error when resolveTagToCommit fails on a malformed tag ref")
}
}
// ── AuthorFromConfig ──────────────────────────────────────────────────────────
func TestAuthorFromConfigDoesNotPanic(t *testing.T) {
repo, dir := newTestRepo(t)
addCommit(t, repo, dir, "chore: init", "init")
// May return empty strings in CI where ~/.gitconfig has no user section — must not panic
name, email := AuthorFromConfig(repo)
_ = name
_ = email
}
func TestAuthorFromConfigLocalOverride(t *testing.T) {
repo, dir := newTestRepo(t)
addCommit(t, repo, dir, "chore: init", "init")
// Write a local git config with user.name / user.email
localCfg := `[user]
name = LocalUser
email = local@example.com
`
if err := os.WriteFile(filepath.Join(dir, ".git", "config"), []byte(localCfg), 0644); err != nil {
t.Fatal(err)
}
// Reload repo so it picks up the config file we just wrote
repo2, err := gogit.PlainOpen(dir)
if err != nil {
t.Fatal(err)
}
name, email := AuthorFromConfig(repo2)
if name != "LocalUser" {
t.Errorf("name = %q, want LocalUser", name)
}
if email != "local@example.com" {
t.Errorf("email = %q, want local@example.com", email)
}
}
// ── Push with a real bare remote ──────────────────────────────────────────────
func TestPushWithBareRemote(t *testing.T) {
// Create a bare repo to act as the remote
remoteDir := t.TempDir()
if _, err := gogit.PlainInit(remoteDir, true); err != nil {
t.Fatal(err)
}
// Create the working repo
repo, dir := newTestRepo(t)
addCommit(t, repo, dir, "fix: something", "v1")
addTag(t, repo, "v1.2.0")
// Wire the bare repo as origin
if _, err := repo.CreateRemote(&gitconfig.RemoteConfig{
Name: "origin",
URLs: []string{remoteDir},
}); err != nil {
t.Fatal(err)
}
// Push with a token (exercises the auth branch even though local transport ignores it)
err := Push(repo, "master", "v1.2.0", "dummy-token")
if err != nil {
t.Fatalf("Push to bare remote failed: %v", err)
}
// Push again — should hit the NoErrAlreadyUpToDate branch and return nil
err = Push(repo, "master", "v1.2.0", "dummy-token")
if err != nil {
t.Fatalf("second Push returned unexpected error: %v", err)
}
}
+103
View File
@@ -0,0 +1,103 @@
package glclient
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"net/url"
"strings"
"time"
)
// Client is a minimal GitLab API client covering only the Releases endpoint.
type Client struct {
baseURL string
token string
projectID string // numeric ID or URL-encoded namespace/project
httpClient *http.Client
}
// New creates a Client. baseURL is the GitLab instance root (e.g. "https://gitlab.example.com").
// project can be a numeric ID ("123") or a namespace/project path ("group/myapp").
func New(baseURL, token, project string) *Client {
return &Client{
baseURL: strings.TrimRight(baseURL, "/"),
token: token,
projectID: encodeProjectPath(project),
httpClient: &http.Client{
Timeout: 30 * time.Second,
},
}
}
// endpoint builds a full API URL for the given resource under the project.
// Uses url.URL{Path, RawPath} to preserve %2F encoding in the project path —
// url.Parse would silently decode %2F back to / in the Path field.
func (c *Client) endpoint(resource string) string {
rawPath := fmt.Sprintf("/api/v4/projects/%s/%s", c.projectID, resource)
u, err := url.Parse(c.baseURL)
if err != nil {
return c.baseURL + rawPath // fallback, handles only simple cases
}
u.Path = strings.ReplaceAll(rawPath, "%2F", "/")
u.RawPath = rawPath
return u.String()
}
// encodeProjectPath URL-encodes a GitLab project identifier for use in an API path.
// Numeric IDs ("123") pass through unchanged.
// Path-style IDs ("group/project") have each segment encoded and slashes replaced with %2F,
// because url.PathEscape does not encode / (it is a valid path separator).
func encodeProjectPath(project string) string {
parts := strings.Split(project, "/")
for i, p := range parts {
parts[i] = url.PathEscape(p)
}
return strings.Join(parts, "%2F")
}
type createReleaseRequest struct {
Name string `json:"name"`
TagName string `json:"tag_name"`
Description string `json:"description"`
}
// CreateRelease creates a GitLab release on an existing tag.
// The tag must already be pushed to the remote before calling this.
func (c *Client) CreateRelease(ctx context.Context, tagName, description string) error {
// json.Marshal never fails for a struct with only string fields
body, _ := json.Marshal(createReleaseRequest{
Name: tagName,
TagName: tagName,
Description: description,
})
endpoint := c.endpoint("releases")
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(body))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("PRIVATE-TOKEN", c.token)
resp, err := c.httpClient.Do(req)
if err != nil {
return fmt.Errorf("GitLab API request failed: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
var errBody struct {
Message string `json:"message"`
}
json.NewDecoder(resp.Body).Decode(&errBody) //nolint:errcheck
if errBody.Message != "" {
return fmt.Errorf("GitLab API returned %d: %s", resp.StatusCode, errBody.Message)
}
return fmt.Errorf("GitLab API returned %d", resp.StatusCode)
}
return nil
}
+151
View File
@@ -0,0 +1,151 @@
package glclient
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
func TestCreateRelease(t *testing.T) {
var received createReleaseRequest
var gotToken string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
t.Errorf("method = %s, want POST", r.Method)
}
gotToken = r.Header.Get("PRIVATE-TOKEN")
json.NewDecoder(r.Body).Decode(&received) //nolint:errcheck
w.WriteHeader(http.StatusCreated)
w.Write([]byte(`{}`)) //nolint:errcheck
}))
defer srv.Close()
client := New(srv.URL, "my-token", "123")
err := client.CreateRelease(context.Background(), "v1.2.4", "## v1.2.4\n\n### Bug Fixes\n- patch something")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if gotToken != "my-token" {
t.Errorf("PRIVATE-TOKEN = %q, want %q", gotToken, "my-token")
}
if received.TagName != "v1.2.4" {
t.Errorf("tag_name = %q, want %q", received.TagName, "v1.2.4")
}
if received.Name != "v1.2.4" {
t.Errorf("name = %q, want %q", received.Name, "v1.2.4")
}
if received.Description == "" {
t.Error("description should not be empty")
}
}
func TestCreateReleaseAPIError(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusUnprocessableEntity)
w.Write([]byte(`{"message":"Tag already has a release"}`)) //nolint:errcheck
}))
defer srv.Close()
client := New(srv.URL, "token", "42")
err := client.CreateRelease(context.Background(), "v1.2.4", "notes")
if err == nil {
t.Fatal("expected error for 422 response")
}
if err.Error() == "" {
t.Error("error message should not be empty")
}
}
func TestProjectPathEncoding(t *testing.T) {
var gotPath string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// EscapedPath returns RawPath when set, preserving %2F encoding.
// r.URL.Path is always decoded so we must not use it here.
gotPath = r.URL.EscapedPath()
w.WriteHeader(http.StatusCreated)
w.Write([]byte(`{}`)) //nolint:errcheck
}))
defer srv.Close()
client := New(srv.URL, "token", "group/my-app")
client.CreateRelease(context.Background(), "v1.0.0", "") //nolint:errcheck
// "group/my-app" must be URL-encoded as "group%2Fmy-app"
if gotPath != "/api/v4/projects/group%2Fmy-app/releases" {
t.Errorf("path = %q, want /api/v4/projects/group%%2Fmy-app/releases", gotPath)
}
}
func TestEndpointFallback(t *testing.T) {
// "://" is an invalid URL that url.Parse will reject,
// exercising the fallback branch in endpoint().
c := New("://invalid", "token", "123")
got := c.endpoint("releases")
want := "://invalid/api/v4/projects/123/releases"
if got != want {
t.Errorf("endpoint fallback = %q, want %q", got, want)
}
}
func TestCreateReleaseStatusNoMessage(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
w.Write([]byte(`not json`)) //nolint:errcheck
}))
defer srv.Close()
err := New(srv.URL, "token", "42").CreateRelease(context.Background(), "v1.0.0", "notes")
if err == nil {
t.Fatal("expected error for 500 response")
}
// Should contain just the status code, no message field
if !strings.Contains(err.Error(), "500") {
t.Errorf("error should mention status 500: %v", err)
}
}
func TestCreateReleaseInvalidURL(t *testing.T) {
// "://" is unparseable as a URL scheme, so endpoint() returns the raw fallback
// string and http.NewRequestWithContext fails when it tries to parse it.
c := New("://", "token", "42")
err := c.CreateRelease(context.Background(), "v1.0.0", "notes")
if err == nil {
t.Fatal("expected error for client with invalid base URL")
}
}
func TestCreateReleaseNetworkError(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {}))
srv.Close() // close immediately so the request fails at TCP level
err := New(srv.URL, "token", "42").CreateRelease(context.Background(), "v1.0.0", "notes")
if err == nil {
t.Fatal("expected network error after server is closed")
}
}
// FuzzEncodeProjectPath verifies that the encoder never panics and never emits
// a raw slash when the input contains one.
func FuzzEncodeProjectPath(f *testing.F) {
f.Add("group/project")
f.Add("123")
f.Add("")
f.Add("group/subgroup/project")
f.Add("a/b/c/d")
f.Add("/leading/slash")
f.Add("trailing/slash/")
f.Add(strings.Repeat("a/", 50))
f.Fuzz(func(t *testing.T, project string) {
result := encodeProjectPath(project)
// A slash in the input must NOT appear as a literal slash in the result
if strings.Contains(project, "/") && strings.Contains(result, "/") {
t.Errorf("encodeProjectPath(%q) = %q: contains unencoded slash", project, result)
}
})
}
+64
View File
@@ -0,0 +1,64 @@
package maven
import (
"fmt"
"os"
"regexp"
"strings"
)
var parentBlockRe = regexp.MustCompile(`(?s)<parent>.*?</parent>`)
var versionTagRe = regexp.MustCompile(`<version>([^<]+)</version>`)
// ReadVersion returns the project version from a pom.xml file.
// It ignores the <parent> block version and dependency versions.
func ReadVersion(pomPath string) (string, error) {
data, err := os.ReadFile(pomPath)
if err != nil {
return "", fmt.Errorf("read %s: %w", pomPath, err)
}
// Strip <parent>…</parent> before searching so we don't pick up the parent version.
stripped := parentBlockRe.ReplaceAllString(string(data), "")
m := versionTagRe.FindStringSubmatch(stripped)
if m == nil {
return "", fmt.Errorf("no <version> element found in %s", pomPath)
}
return strings.TrimSpace(m[1]), nil
}
// WriteVersion replaces the project version in a pom.xml file in-place.
// oldVersion must match what ReadVersion returned. The <parent> version is never touched.
func WriteVersion(pomPath, oldVersion, newVersion string) error {
data, err := os.ReadFile(pomPath)
if err != nil {
return fmt.Errorf("read %s: %w", pomPath, err)
}
updated := replaceProjectVersion(string(data), oldVersion, newVersion)
if updated == string(data) {
return fmt.Errorf("version %q not found in %s", oldVersion, pomPath)
}
return os.WriteFile(pomPath, []byte(updated), 0644)
}
// replaceProjectVersion replaces the first <version>oldVersion</version> that is not
// inside a <parent> block. This preserves the parent version and dependency versions.
func replaceProjectVersion(content, oldVersion, newVersion string) string {
target := "<version>" + oldVersion + "</version>"
replacement := "<version>" + newVersion + "</version>"
// If there is a <parent> block, start searching after it.
if idx := strings.Index(content, "</parent>"); idx >= 0 {
after := content[idx:]
if pos := strings.Index(after, target); pos >= 0 {
abs := idx + pos
return content[:abs] + replacement + content[abs+len(target):]
}
return content
}
return strings.Replace(content, target, replacement, 1)
}
+213
View File
@@ -0,0 +1,213 @@
package maven
import (
"os"
"path/filepath"
"strings"
"testing"
)
const simplePom = `<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0">
<modelVersion>4.0.0</modelVersion>
<groupId>com.example</groupId>
<artifactId>myapp</artifactId>
<version>1.2.3</version>
<name>My App</name>
</project>`
const pomWithParent = `<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.2.0</version>
</parent>
<groupId>com.example</groupId>
<artifactId>myapp</artifactId>
<version>1.2.3</version>
</project>`
const pomWithSnapshot = `<?xml version="1.0" encoding="UTF-8"?>
<project>
<artifactId>myapp</artifactId>
<version>1.2.4-SNAPSHOT</version>
</project>`
func writePom(t *testing.T, content string) string {
t.Helper()
path := filepath.Join(t.TempDir(), "pom.xml")
if err := os.WriteFile(path, []byte(content), 0644); err != nil {
t.Fatal(err)
}
return path
}
func TestReadVersion(t *testing.T) {
cases := []struct {
name string
content string
want string
}{
{"simple pom", simplePom, "1.2.3"},
{"pom with parent", pomWithParent, "1.2.3"},
{"snapshot version", pomWithSnapshot, "1.2.4-SNAPSHOT"},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
got, err := ReadVersion(writePom(t, c.content))
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got != c.want {
t.Errorf("got %q, want %q", got, c.want)
}
})
}
}
func TestReadVersionParentNotMatched(t *testing.T) {
// ReadVersion must return the project version, NOT the parent version (3.2.0).
got, err := ReadVersion(writePom(t, pomWithParent))
if err != nil {
t.Fatal(err)
}
if got != "1.2.3" {
t.Errorf("got %q, want 1.2.3 (parent version must be ignored)", got)
}
}
func TestWriteVersion(t *testing.T) {
cases := []struct {
name string
content string
oldVersion string
newVersion string
wantInFile string
}{
{"simple replace", simplePom, "1.2.3", "1.2.4", "<version>1.2.4</version>"},
{"with parent", pomWithParent, "1.2.3", "1.2.4", "<version>1.2.4</version>"},
{"snapshot to release", pomWithSnapshot, "1.2.4-SNAPSHOT", "1.2.4", "<version>1.2.4</version>"},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
path := writePom(t, c.content)
if err := WriteVersion(path, c.oldVersion, c.newVersion); err != nil {
t.Fatalf("unexpected error: %v", err)
}
data, _ := os.ReadFile(path)
content := string(data)
if !contains(content, c.wantInFile) {
t.Errorf("expected %q in updated pom, got:\n%s", c.wantInFile, content)
}
// Parent version must be unchanged in the parent block case.
if c.name == "with parent" && !contains(content, "<version>3.2.0</version>") {
t.Error("parent version was modified")
}
})
}
}
func TestWriteVersionParentPreserved(t *testing.T) {
path := writePom(t, pomWithParent)
if err := WriteVersion(path, "1.2.3", "1.2.4"); err != nil {
t.Fatal(err)
}
got, _ := ReadVersion(path)
if got != "1.2.4" {
t.Errorf("project version = %q, want 1.2.4", got)
}
data, _ := os.ReadFile(path)
if !contains(string(data), "<version>3.2.0</version>") {
t.Error("parent version was modified")
}
}
func TestReadVersionMissingFile(t *testing.T) {
_, err := ReadVersion(filepath.Join(t.TempDir(), "nonexistent.xml"))
if err == nil {
t.Error("expected error for missing pom file")
}
}
func TestReadVersionNoVersionTag(t *testing.T) {
_, err := ReadVersion(writePom(t, "<project><artifactId>app</artifactId></project>"))
if err == nil {
t.Error("expected error when no <version> tag present")
}
}
func TestWriteVersionMissingFile(t *testing.T) {
err := WriteVersion(filepath.Join(t.TempDir(), "nosuchfile.xml"), "1.0", "1.1")
if err == nil {
t.Error("expected error for missing pom file")
}
}
func TestWriteVersionNotFound(t *testing.T) {
err := WriteVersion(writePom(t, simplePom), "9.9.9", "9.9.10")
if err == nil {
t.Error("expected error when old version not found in pom")
}
}
func TestReplaceVersionParentNoMatch(t *testing.T) {
// Has </parent> but the target version only appears inside the parent block,
// not after it. replaceProjectVersion should return content unchanged.
content := `<project><parent><version>3.0.0</version></parent></project>`
result := replaceProjectVersion(content, "3.0.0", "4.0.0")
if result != content {
t.Errorf("expected content unchanged; got:\n%s", result)
}
}
func contains(s, sub string) bool { return strings.Contains(s, sub) }
// FuzzReadVersion verifies that ReadVersion never panics on arbitrary file content.
func FuzzReadVersion(f *testing.F) {
f.Add(simplePom)
f.Add(pomWithParent)
f.Add(pomWithSnapshot)
f.Add("")
f.Add("<project></project>")
f.Add("<version>1.0</version>")
f.Add("\x00\x01\xff")
f.Fuzz(func(t *testing.T, content string) {
path := filepath.Join(t.TempDir(), "pom.xml")
os.WriteFile(path, []byte(content), 0644) //nolint:errcheck
// Must not panic regardless of content
ReadVersion(path) //nolint:errcheck
})
}
// FuzzReplaceProjectVersion verifies the internal replacer is robust against arbitrary inputs.
func FuzzReplaceProjectVersion(f *testing.F) {
f.Add(simplePom, "1.2.3", "1.2.4")
f.Add(pomWithParent, "1.2.3", "2.0.0")
f.Add("", "1.0", "2.0")
f.Add("<version>1.0</version>", "1.0", "1.1")
f.Add("\x00", "", "1.0")
f.Fuzz(func(t *testing.T, content, oldVersion, newVersion string) {
result := replaceProjectVersion(content, oldVersion, newVersion)
// Result must always be a string (no panic)
// If a replacement happened, oldVersion must not appear where newVersion was inserted
if oldVersion == newVersion {
return
}
if result != content {
// Replacement occurred — newVersion must appear in result
if !strings.Contains(result, "<version>"+newVersion+"</version>") {
t.Errorf("replacement happened but newVersion not found in result")
}
}
})
}
+65
View File
@@ -0,0 +1,65 @@
package notes
import (
"fmt"
"regexp"
"strings"
"git.k3nny.fr/releaser/internal/commits"
)
// headerSubjectRe captures the subject (description) part of a conventional commit header.
var headerSubjectRe = regexp.MustCompile(`(?i)^\w+(?:\([^)]*\))?!?\s*:\s*(.+)`)
// Generate produces grouped markdown release notes from a list of commit messages.
// Commits are grouped into Breaking Changes, Features, and Bug Fixes.
// Commits with no releasable type are omitted.
func Generate(tagName string, messages []string) string {
var breaking, feats, fixes []string
for _, msg := range messages {
t := commits.Parse(msg)
if t == commits.TypeNone {
continue
}
first := strings.SplitN(strings.TrimSpace(msg), "\n", 2)[0]
subject := extractSubject(first)
switch t {
case commits.TypeBreaking:
breaking = append(breaking, subject)
case commits.TypeFeat:
feats = append(feats, subject)
case commits.TypeFix:
fixes = append(fixes, subject)
}
}
var sb strings.Builder
fmt.Fprintf(&sb, "## %s\n", tagName)
writeSection(&sb, "Breaking Changes", breaking)
writeSection(&sb, "Features", feats)
writeSection(&sb, "Bug Fixes", fixes)
return strings.TrimRight(sb.String(), "\n")
}
func writeSection(sb *strings.Builder, title string, items []string) {
if len(items) == 0 {
return
}
fmt.Fprintf(sb, "\n### %s\n", title)
for _, item := range items {
fmt.Fprintf(sb, "- %s\n", item)
}
}
// extractSubject returns the description part of a conventional commit header.
// Falls back to the raw header if the pattern does not match.
func extractSubject(header string) string {
m := headerSubjectRe.FindStringSubmatch(header)
if m != nil {
return strings.TrimSpace(m[1])
}
return strings.TrimSpace(header)
}
+109
View File
@@ -0,0 +1,109 @@
package notes
import (
"strings"
"testing"
)
func TestGenerate(t *testing.T) {
messages := []string{
"feat: add OAuth2 login",
"fix: correct null pointer in user service",
"feat(search): improve performance",
"chore: update dependencies", // ignored
"Merge branch 'feature/x' into main", // ignored
"feat!: remove legacy API",
"fix: handle empty response",
"WIP something", // ignored
}
got := Generate("v1.2.4", messages)
must := []string{
"## v1.2.4",
"### Breaking Changes",
"- remove legacy API",
"### Features",
"- add OAuth2 login",
"- improve performance",
"### Bug Fixes",
"- correct null pointer in user service",
"- handle empty response",
}
for _, want := range must {
if !strings.Contains(got, want) {
t.Errorf("missing %q in output:\n%s", want, got)
}
}
absent := []string{"update dependencies", "Merge branch", "WIP"}
for _, s := range absent {
if strings.Contains(got, s) {
t.Errorf("unexpected %q in output:\n%s", s, got)
}
}
}
func TestGenerateOnlyFixes(t *testing.T) {
messages := []string{
"fix: patch SQL injection",
"fix: escape HTML output",
}
got := Generate("v1.2.1", messages)
if strings.Contains(got, "### Features") {
t.Error("Features section should be absent when there are no feat commits")
}
if strings.Contains(got, "### Breaking Changes") {
t.Error("Breaking Changes section should be absent")
}
if !strings.Contains(got, "### Bug Fixes") {
t.Error("Bug Fixes section should be present")
}
}
func TestGenerateEmpty(t *testing.T) {
got := Generate("v1.2.0", []string{"chore: bump deps", "docs: update readme"})
if strings.Contains(got, "###") {
t.Errorf("no sections expected when no releasable commits, got:\n%s", got)
}
}
func TestExtractSubject(t *testing.T) {
cases := []struct {
header string
want string
}{
{"feat: add login", "add login"},
{"feat(auth): add OAuth2", "add OAuth2"},
{"feat!: remove API", "remove API"},
{"FIX:typo", "typo"},
{"plain message", "plain message"},
}
for _, c := range cases {
got := extractSubject(c.header)
if got != c.want {
t.Errorf("extractSubject(%q) = %q, want %q", c.header, got, c.want)
}
}
}
// FuzzGenerate verifies that Generate never panics on arbitrary inputs and
// always includes the tag name in the output.
func FuzzGenerate(f *testing.F) {
f.Add("v1.2.4", "feat: add thing")
f.Add("v1.0.0", "fix: patch")
f.Add("1.2.0", "feat!: breaking change")
f.Add("v0.0.1", "")
f.Add("", "feat: msg")
f.Add("v1.0.0", "BREAKING CHANGE: dropped")
f.Add("tag\nwith\nnewline", "feat: something")
f.Add("v1.0.0", strings.Repeat("feat: x\n", 1000))
f.Fuzz(func(t *testing.T, tag, msg string) {
result := Generate(tag, []string{msg})
if !strings.Contains(result, tag) {
t.Errorf("output does not contain tag %q\noutput: %s", tag, result)
}
})
}
+19
View File
@@ -0,0 +1,19 @@
package version
import (
"fmt"
"git.k3nny.fr/releaser/internal/commits"
)
// Next computes the next version string (without tag prefix, e.g. "1.2.4").
// currentPatch is -1 when no tag exists yet (first release will be X.Y.0).
// Returns ("", false) when there are no releasable commits.
func Next(major, minor, currentPatch int, types []commits.Type) (string, bool) {
for _, t := range types {
if t != commits.TypeNone {
return fmt.Sprintf("%d.%d.%d", major, minor, currentPatch+1), true
}
}
return "", false
}
+73
View File
@@ -0,0 +1,73 @@
package version
import (
"testing"
"git.k3nny.fr/releaser/internal/commits"
)
func TestNext(t *testing.T) {
cases := []struct {
desc string
major, minor int
currentPatch int
types []commits.Type
want string
wantOk bool
}{
{
desc: "first release with feat",
major: 1, minor: 2, currentPatch: -1,
types: []commits.Type{commits.TypeFeat},
want: "1.2.0",
wantOk: true,
},
{
desc: "bump from patch 3",
major: 1, minor: 2, currentPatch: 3,
types: []commits.Type{commits.TypeFix},
want: "1.2.4",
wantOk: true,
},
{
desc: "breaking change still bumps patch on release branch",
major: 2, minor: 0, currentPatch: 1,
types: []commits.Type{commits.TypeBreaking},
want: "2.0.2",
wantOk: true,
},
{
desc: "only chore commits — nothing to release",
major: 1, minor: 2, currentPatch: 5,
types: []commits.Type{commits.TypeNone, commits.TypeNone},
want: "",
wantOk: false,
},
{
desc: "no commits at all",
major: 1, minor: 2, currentPatch: 0,
types: nil,
want: "",
wantOk: false,
},
{
desc: "one releasable commit among chores",
major: 1, minor: 3, currentPatch: 7,
types: []commits.Type{commits.TypeNone, commits.TypeFix, commits.TypeNone},
want: "1.3.8",
wantOk: true,
},
}
for _, c := range cases {
t.Run(c.desc, func(t *testing.T) {
got, ok := Next(c.major, c.minor, c.currentPatch, c.types)
if ok != c.wantOk {
t.Errorf("ok=%v, want %v", ok, c.wantOk)
}
if got != c.want {
t.Errorf("got %q, want %q", got, c.want)
}
})
}
}