diff --git a/CHANGELOG.md b/CHANGELOG.md index 0af34d9..c354a31 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,12 @@ All notable changes to this project will be documented in this file. Format: [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). +## [1.1.1] - 2026-07-07 + +### Added + +- **`--verbose` flag** — prints configuration table (each key, value, and source: `default` / `config file` / `env: VARNAME` / `flag: --name`), lists every commit since the last tag with its parsed type and version-bump decision, and explains the final version choice; output goes to stderr so it never pollutes scripts that capture stdout + ## [1.1.0] - 2026-07-07 ### Added diff --git a/README.md b/README.md index bad6f82..8cd4885 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # releaser -![release](https://img.shields.io/badge/release-v1.1.0-blue.svg) +![release](https://img.shields.io/badge/release-v1.1.1-blue.svg) A CI-friendly release automation tool for GitFlow workflows using Conventional Commits. @@ -64,6 +64,9 @@ releaser --branch release/1.2 # Write changelog to a custom file releaser --changelog-file CHANGES.md +# Show configuration sources, commit list, and version decision +releaser --verbose --dry-run + # Target a specific pom.xml releaser --pom path/to/pom.xml diff --git a/ROADMAP.md b/ROADMAP.md index 486d4d0..2bbc22f 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -66,6 +66,7 @@ - [x] ~~Integration tests against a real Git repo (with fixture commits and tags)~~ — ✓ shipped v0.4.0 (96% coverage, real in-memory repos) - [x] ~~Cross-compilation in CI (linux/amd64, linux/arm64, darwin/amd64)~~ — ✓ shipped v0.4.0 (Gitea release workflow, + darwin/arm64 + windows/amd64) +- [x] ~~`--verbose` flag~~ — ✓ shipped v1.1.1 (shows config sources, commit analysis, version decision) - [ ] Documentation site ## Future / backlog diff --git a/cmd/main.go b/cmd/main.go index 67d3502..e391442 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -72,6 +72,7 @@ var exitFn = os.Exit func newRootCmd() *cobra.Command { var ( init_ bool + verbose bool dryRun bool noPush bool noRelease bool @@ -97,6 +98,7 @@ func newRootCmd() *cobra.Command { patternSet = cmd.Flags().Changed("branch-pattern") return run(options{ init: init_, + verbose: verbose, repoPath: repoPath, branchOverride: branchOverride, pomOverride: pomOverride, @@ -115,6 +117,7 @@ func newRootCmd() *cobra.Command { } root.Flags().BoolVar(&init_, "init", false, "create a default .releaser.yml in the repository and exit") + root.Flags().BoolVar(&verbose, "verbose", false, "print configuration sources, commit list, and version decision") root.Flags().BoolVar(&dryRun, "dry-run", false, "print next version without making changes") root.Flags().BoolVar(&noPush, "no-push", false, "create commit and tag locally without pushing or creating a GitLab release") root.Flags().BoolVar(&noRelease, "no-release", false, "push commit and tag but skip creating the GitLab release") @@ -142,6 +145,7 @@ func main() { type options struct { init bool + verbose bool repoPath string branchOverride string pomOverride string @@ -157,6 +161,40 @@ type options struct { tagOnly bool } +// vlog prints a verbose line to stderr, prefixed with "[verbose] ", when verbose is true. +func vlog(verbose bool, format string, args ...any) { + if verbose { + fmt.Fprintf(os.Stderr, "[verbose] "+format+"\n", args...) + } +} + +func printVerboseConfig(cfg config.Config, src config.Sources) { + fmt.Fprintln(os.Stderr, "[verbose] configuration:") + rows := []struct{ key, val string }{ + {"git.tag_prefix", cfg.Git.TagPrefix}, + {"git.branch_pattern", cfg.Git.BranchPattern}, + {"git.commit_message", cfg.Git.CommitMessage}, + {"git.author_name", cfg.Git.AuthorName}, + {"git.author_email", cfg.Git.AuthorEmail}, + {"maven.pom_path", cfg.Maven.PomPath}, + {"gitlab.url", cfg.GitLab.URL}, + {"gitlab.token", func() string { + if cfg.GitLab.Token != "" { + return "(set)" + } + return "(not set)" + }()}, + {"gitlab.project", cfg.GitLab.Project}, + } + for _, r := range rows { + source := src[r.key] + if source == "" { + source = "default" + } + fmt.Fprintf(os.Stderr, " %-25s = %-40s [%s]\n", r.key, r.val, source) + } +} + func initConfig(absRepo string) error { path := filepath.Join(absRepo, ".releaser.yml") if _, err := os.Stat(path); err == nil { @@ -177,24 +215,46 @@ func run(o options) error { } if o.init { + vlog(o.verbose, "creating .releaser.yml in %s", absRepo) return initConfig(absRepo) } - cfg, err := config.Load(absRepo) + var ( + cfg config.Config + src config.Sources + ) + if o.verbose { + cfg, src, err = config.LoadWithSources(absRepo) + } else { + cfg, err = config.Load(absRepo) + } if err != nil { return err } - cfg.ApplyEnv() + cfg.ApplyEnvWithSources(src) // CLI flags take precedence over config file and env vars if o.tagPrefixSet { cfg.Git.TagPrefix = o.tagPrefixFlag + if src != nil { + src["git.tag_prefix"] = "flag: --tag-prefix" + } } if o.pomOverride != "" { cfg.Maven.PomPath = o.pomOverride + if src != nil { + src["maven.pom_path"] = "flag: --pom" + } } if o.patternSet { cfg.Git.BranchPattern = o.patternFlag + if src != nil { + src["git.branch_pattern"] = "flag: --branch-pattern" + } + } + + if o.verbose { + printVerboseConfig(cfg, src) } // --- Git --- @@ -217,6 +277,8 @@ func run(o options) error { } info.TagPrefix = cfg.Git.TagPrefix + vlog(o.verbose, "branch: %s → major=%d, minor=%d (pinned by branch)", branchName, info.Major, info.Minor) + // --- Dirty check (before any changes) --- // Skipped in --no-commit mode: the user intentionally has changes in flight. if !o.dryRun && !o.noCommit { @@ -235,6 +297,14 @@ func run(o options) error { return fmt.Errorf("find latest tag: %w", err) } + if o.verbose { + if lastTag == "" { + vlog(true, "last tag: none — scanning all commits") + } else { + vlog(true, "last tag: %s → current patch=%d", lastTag, currentPatch) + } + } + // --- Commit range --- var messages []string if lastTag == "" { @@ -256,6 +326,26 @@ func run(o options) error { types[i] = commits.Parse(msg) } + if o.verbose { + vlog(true, "commits analyzed (%d):", len(messages)) + for i, msg := range messages { + first := strings.SplitN(strings.TrimSpace(msg), "\n", 2)[0] + t := types[i] + var decision string + switch t { + case commits.TypeBreaking: + decision = "breaking → patch bump" + case commits.TypeFeat: + decision = "feat → patch bump" + case commits.TypeFix: + decision = "fix → patch bump" + default: + decision = "ignored" + } + fmt.Fprintf(os.Stderr, " %-60s %s\n", first, decision) + } + } + nextVersion, ok := semver.Next(info.Major, info.Minor, currentPatch, types) if !ok { fmt.Fprintln(os.Stderr, "info: no releasable commits found") @@ -264,6 +354,16 @@ func run(o options) error { nextTag := info.TagName(nextVersion) + if o.verbose { + highestType := commits.TypeNone + for _, t := range types { + if t > highestType { + highestType = t + } + } + vlog(true, "version decision: highest type=%s → next=%s (tag: %s)", highestType, nextVersion, nextTag) + } + fmt.Printf("next version: %s (tag: %s)\n", nextVersion, nextTag) if o.dryRun { diff --git a/cmd/main_test.go b/cmd/main_test.go index acb1135..a8a8745 100644 --- a/cmd/main_test.go +++ b/cmd/main_test.go @@ -2,6 +2,7 @@ package main import ( "errors" + "io" "net/http" "net/http/httptest" "os" @@ -656,3 +657,46 @@ func TestRunChangelogFile(t *testing.T) { t.Error("expected CHANGES.md to be created") } } + +func TestRunVerbose(t *testing.T) { + _, dir := setupRepo(t) + addFile(t, dir, "x.go", "// feat") + repo, _ := gogit.PlainOpen(dir) + w, _ := repo.Worktree() + w.Add("x.go") + w.Commit("feat: add new thing", &gogit.CommitOptions{Author: testSig()}) + + // Capture stderr output by redirecting it temporarily. + old := os.Stderr + r, wPipe, _ := os.Pipe() + os.Stderr = wPipe + + err := execCmd(t, "--dry-run", "--verbose", "--branch", "release/1.2", "--repo", dir) + + wPipe.Close() + os.Stderr = old + + rawBytes, _ := io.ReadAll(r) + output := string(rawBytes) + + if err != nil { + t.Fatalf("--verbose: unexpected error: %v", err) + } + + checks := []string{ + "configuration:", + "git.tag_prefix", + "[default]", + "branch: release/1.2", + "major=1, minor=2", + "commits analyzed", + "feat: add new thing", + "feat → patch bump", + "version decision:", + } + for _, want := range checks { + if !strings.Contains(output, want) { + t.Errorf("--verbose output missing %q\nfull output:\n%s", want, output) + } + } +} diff --git a/internal/config/config.go b/internal/config/config.go index c8ab127..438b636 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -50,42 +50,123 @@ func defaults() Config { } } +// Sources records where each config value came from. +// Keys are "section.field" (e.g. "git.tag_prefix"). +// Values are one of: "default", "config file", "env: VARNAME", "flag: --flag-name". +type Sources map[string]string + +func defaultSources() Sources { + return Sources{ + "git.tag_prefix": "default", + "git.branch_pattern": "default", + "git.commit_message": "default", + "git.author_name": "default", + "git.author_email": "default", + "maven.pom_path": "default", + "gitlab.url": "default", + "gitlab.token": "default", + "gitlab.project": "default", + } +} + // 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, _, err := LoadWithSources(dir) + return cfg, err +} + +// LoadWithSources is like Load but also returns a Sources map recording where each +// value came from ("default" or "config file"). +func LoadWithSources(dir string) (Config, Sources, error) { cfg := defaults() + src := defaultSources() data, err := os.ReadFile(filepath.Join(dir, filename)) if errors.Is(err, os.ErrNotExist) { - return cfg, nil + return cfg, src, nil } if err != nil { - return cfg, fmt.Errorf("read %s: %w", filename, err) + return cfg, src, fmt.Errorf("read %s: %w", filename, err) } + // Unmarshal into cfg (merges over defaults). if err := yaml.Unmarshal(data, &cfg); err != nil { - return cfg, fmt.Errorf("parse %s: %w", filename, err) + return cfg, src, fmt.Errorf("parse %s: %w", filename, err) } - return cfg, nil + // Detect which fields the file explicitly set by unmarshaling into a zero overlay. + var overlay Config + _ = yaml.Unmarshal(data, &overlay) + if overlay.Git.TagPrefix != "" { + src["git.tag_prefix"] = "config file" + } + if overlay.Git.BranchPattern != "" { + src["git.branch_pattern"] = "config file" + } + if overlay.Git.CommitMessage != "" { + src["git.commit_message"] = "config file" + } + if overlay.Git.AuthorName != "" { + src["git.author_name"] = "config file" + } + if overlay.Git.AuthorEmail != "" { + src["git.author_email"] = "config file" + } + if overlay.Maven.PomPath != "" { + src["maven.pom_path"] = "config file" + } + if overlay.GitLab.URL != "" { + src["gitlab.url"] = "config file" + } + if overlay.GitLab.Token != "" { + src["gitlab.token"] = "config file" + } + if overlay.GitLab.Project != "" { + src["gitlab.project"] = "config file" + } + + return cfg, src, 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() { + c.ApplyEnvWithSources(nil) +} + +// ApplyEnvWithSources is like ApplyEnv but records the env var name in src for each +// field it fills. src may be nil. +func (c *Config) ApplyEnvWithSources(src Sources) { if c.GitLab.Token == "" { - c.GitLab.Token = os.Getenv("GITLAB_TOKEN") + if v := os.Getenv("GITLAB_TOKEN"); v != "" { + c.GitLab.Token = v + if src != nil { + src["gitlab.token"] = "env: 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 v := os.Getenv("CI_SERVER_URL"); v != "" { + c.GitLab.URL = v + if src != nil { + src["gitlab.url"] = "env: 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") + if src != nil { + src["gitlab.project"] = "env: CI_PROJECT_ID" + } + } else if p := os.Getenv("CI_PROJECT_PATH"); p != "" { + c.GitLab.Project = p + if src != nil { + src["gitlab.project"] = "env: CI_PROJECT_PATH" + } } } }