Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,28 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Fixed
- `releasegen validate --require-changelog-entry` now folds *staged*
index changes into its diff against the base ref. Without this, the
check was a no-op under pre-commit hooks (e.g. prenup), because HEAD
is still the parent commit at that moment and a tree-only diff sees
nothing. Local pre-commit checks now catch missing `[Unreleased]`
entries just as reliably as CI does on a pushed commit. Unstaged
worktree edits and untracked files are deliberately excluded so
unrelated local dirt (build artifacts, half-finished edits, scratch
files) cannot cause false failures, and so the changed-file set
matches what `FileAtIndex` reads for the next-commit comparison.
- `releasegen validate --require-changelog-entry` now compares the
`[Unreleased]` section as it appears in the **git index** (the staged
view of the next commit) rather than the working tree. Previously, a
developer who edited `CHANGELOG.md` but forgot to `git add` it would
see the pre-commit check pass — the worktree had the new entry — even
though the commit being created did not, leaving the gap to be caught
only in CI. The check now predicts the post-commit state correctly:
unstaged changelog edits no longer satisfy the requirement, and
`git commit -a` keeps working because git stages worktree changes
before pre-commit hooks run.

## [[v1.2.0](https://github.com/C2FO/releasegen/releases/tag/v1.2.0)] - 2026-06-18
### Added
- New `releasegen validate` subcommand. Parses every `## [Unreleased]` section
Expand Down
30 changes: 18 additions & 12 deletions cmd/releasegen/validate.go
Original file line number Diff line number Diff line change
Expand Up @@ -195,7 +195,7 @@ func validateAll(
}
log.Debug("require_changelog_entry enabled", "base_ref", base)
var err error
entryProblems, err = collectEntryProblems(ctx, cfg, paths, repo, base, log)
entryProblems, err = collectEntryProblems(ctx, paths, repo, base, log)
if err != nil {
return cliError{code: exitVCSErr, err: err}
}
Expand Down Expand Up @@ -263,7 +263,6 @@ func validatePaths(cfg *config.Config, paths []string, log *slog.Logger) error {
// them with content-validation problems in one report.
func collectEntryProblems(
ctx context.Context,
cfg *config.Config,
paths []string,
repo *vcs.GitRepo,
base string,
Expand Down Expand Up @@ -322,7 +321,7 @@ func collectEntryProblems(
if !st.nonChangelogHit {
continue
}
gained, err := unreleasedGained(ctx, repo, base, st.changelog, cfg.RepoRoot)
gained, err := unreleasedGained(ctx, repo, base, st.changelog)
if err != nil {
problems = append(problems, changelogProblem{
path: st.changelog,
Expand Down Expand Up @@ -370,25 +369,32 @@ func assignModule(f string, prefixes map[string]string, hasRoot bool) string {
}

// unreleasedGained reports whether the [Unreleased] section of changelogPath
// has more non-whitespace lines at HEAD than it did at base. A net-zero or
// shrinking change counts as "no new content," which catches both the
// "didn't touch the changelog" case and the (rarer) "edited a versioned
// section but not [Unreleased]" case.
// has more non-whitespace lines in the *next commit* than it did at base.
// A net-zero or shrinking change counts as "no new content," which catches
// both the "didn't touch the changelog" case and the (rarer) "edited a
// versioned section but not [Unreleased]" case.
//
// We deliberately read the index (staged) version rather than the working
// tree: when this runs as a pre-commit hook, only what is staged will be
// committed, and we want validation to predict the post-commit state. An
// unstaged worktree edit to CHANGELOG.md will not survive the commit, so
// it must not satisfy this check — otherwise developers can silently
// commit code without their changelog updates and only discover the gap
// in CI.
func unreleasedGained(
ctx context.Context,
repo *vcs.GitRepo,
base, changelogPath, repoRoot string,
base, changelogPath string,
) (bool, error) {
baseBody, err := repo.FileAtRef(ctx, base, changelogPath)
if err != nil {
return false, err
}
headPath := filepath.Join(repoRoot, changelogPath)
headBytes, err := os.ReadFile(headPath) //nolint:gosec // path comes from repo discovery
headBody, err := repo.FileAtIndex(ctx, changelogPath)
if err != nil {
return false, fmt.Errorf("read %s: %w", changelogPath, err)
return false, fmt.Errorf("read %s from index: %w", changelogPath, err)
}
headLines := countNonWhitespaceLines(changelog.ExtractUnreleased(string(headBytes)))
headLines := countNonWhitespaceLines(changelog.ExtractUnreleased(headBody))
baseLines := countNonWhitespaceLines(changelog.ExtractUnreleased(baseBody))
return headLines > baseLines, nil
}
Expand Down
137 changes: 137 additions & 0 deletions cmd/releasegen/validate_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -453,6 +453,143 @@ func (s *ValidateTestSuite) TestRequireChangelogEntry_EmptyBaseRefUsesDefault()
s.Contains(err.Error(), "origin/main")
}

func (s *ValidateTestSuite) TestRequireChangelogEntry_CatchesStagedChangesPreCommit() {
// Regression: a prenup-style pre-commit hook runs while HEAD is still
// the parent commit. Without folding the index/staged set into
// ChangedFiles, validate would see "nothing changed since base" and
// pass on a developer who staged code without updating the changelog.
// This test asserts the fix: a staged code change with no [Unreleased]
// entry must fail, even though HEAD itself has not moved.
f := s.newRequireEntryFixture()
// DO NOT commit. Stage a source file edit (no changelog update).
full := filepath.Join(f.dir, "svc", "foo.go")
s.Require().NoError(os.WriteFile(full, []byte("package svc\n// staged but uncommitted\n"), 0o600))
_, err := f.wt.Add("svc/foo.go")
s.Require().NoError(err)

repo := f.repoOpen()
cfg := &config.Config{
RepoRoot: f.dir,
RequireChangelogEntry: true,
BaseRef: "base-tag",
}
paths := []string{"CHANGELOG.md", "svc/CHANGELOG.md"}
err = validateAll(context.Background(), cfg, paths, repo, s.log)
s.Require().Error(err, "validate must fail on staged-but-uncommitted code changes without a changelog entry")
s.Contains(err.Error(), "svc/CHANGELOG.md")
s.Contains(err.Error(), "[Unreleased] section gained no new lines")
}

func (s *ValidateTestSuite) TestRequireChangelogEntry_UnstagedChangelogEditDoesNotSatisfyCheck() {
// Regression: a developer stages svc/foo.go, runs the commit, prenup
// fires validate, the check fails because no [Unreleased] entry exists.
// They then edit svc/CHANGELOG.md but forget `git add` and run commit
// again. The worktree now has the new entry, but only the staged tree
// will be committed — and the staged tree still lacks the entry.
// Validate must fail, otherwise the developer silently commits code
// without the matching changelog line and only CI catches it.
f := s.newRequireEntryFixture()
// Stage a source file.
full := filepath.Join(f.dir, "svc", "foo.go")
s.Require().NoError(os.WriteFile(full, []byte("package svc\n// staged code change\n"), 0o600))
_, err := f.wt.Add("svc/foo.go")
s.Require().NoError(err)
// Edit the changelog in the worktree but DO NOT stage it.
clPath := filepath.Join(f.dir, "svc", "CHANGELOG.md")
s.Require().NoError(os.WriteFile(
clPath,
[]byte("# Changelog\n\n## [Unreleased]\n### Added\n- did the thing\n"),
0o600,
))

repo := f.repoOpen()
cfg := &config.Config{
RepoRoot: f.dir,
RequireChangelogEntry: true,
BaseRef: "base-tag",
}
paths := []string{"CHANGELOG.md", "svc/CHANGELOG.md"}
err = validateAll(context.Background(), cfg, paths, repo, s.log)
s.Require().Error(err, "validate must reject a changelog edit that was never staged")
s.Contains(err.Error(), "svc/CHANGELOG.md")
s.Contains(err.Error(), "[Unreleased] section gained no new lines")
}

func (s *ValidateTestSuite) TestRequireChangelogEntry_StagedChangelogEditSatisfiesCheck() {
// Companion to UnstagedChangelogEdit*: same setup, but the developer
// remembers to `git add svc/CHANGELOG.md`. With both files staged, the
// next commit will contain both, so validate must pass.
f := s.newRequireEntryFixture()
full := filepath.Join(f.dir, "svc", "foo.go")
s.Require().NoError(os.WriteFile(full, []byte("package svc\n// staged code change\n"), 0o600))
_, err := f.wt.Add("svc/foo.go")
s.Require().NoError(err)
clRel := "svc/CHANGELOG.md"
clPath := filepath.Join(f.dir, clRel)
s.Require().NoError(os.WriteFile(
clPath,
[]byte("# Changelog\n\n## [Unreleased]\n### Added\n- did the thing\n"),
0o600,
))
_, err = f.wt.Add(clRel)
s.Require().NoError(err)

repo := f.repoOpen()
cfg := &config.Config{
RepoRoot: f.dir,
RequireChangelogEntry: true,
BaseRef: "base-tag",
}
paths := []string{"CHANGELOG.md", "svc/CHANGELOG.md"}
s.NoError(validateAll(context.Background(), cfg, paths, repo, s.log))
}

func (s *ValidateTestSuite) TestRequireChangelogEntry_IgnoresUnstagedAndUntrackedDirt() {
// Companion to the staged-only tightening of ChangedFiles. A
// developer with unstaged build-artifact edits and untracked scratch
// files lying around must NOT see false failures from validate: only
// what is actually staged (and therefore in the next commit) should
// be evaluated. Here the developer has properly staged both
// svc/foo.go and svc/CHANGELOG.md, so validate must pass even though
// the worktree is dirty.
f := s.newRequireEntryFixture()
// Stage code + matching changelog entry (the disciplined case).
full := filepath.Join(f.dir, "svc", "foo.go")
s.Require().NoError(os.WriteFile(full, []byte("package svc\n// staged code change\n"), 0o600))
_, err := f.wt.Add("svc/foo.go")
s.Require().NoError(err)
s.Require().NoError(os.WriteFile(
filepath.Join(f.dir, "svc", "CHANGELOG.md"),
[]byte("# Changelog\n\n## [Unreleased]\n### Added\n- did the thing\n"),
0o600,
))
_, err = f.wt.Add("svc/CHANGELOG.md")
s.Require().NoError(err)
// Now sprinkle local dirt: an unstaged edit to a previously
// committed file in the root module and a brand-new untracked file
// in the svc module. Both must be ignored.
s.Require().NoError(os.WriteFile(
filepath.Join(f.dir, "main.go"),
[]byte("package x\n// unstaged scratch edit\n"),
0o600,
))
s.Require().NoError(os.WriteFile(
filepath.Join(f.dir, "svc", "scratch.log"),
[]byte("temp build log\n"),
0o600,
))

repo := f.repoOpen()
cfg := &config.Config{
RepoRoot: f.dir,
RequireChangelogEntry: true,
BaseRef: "base-tag",
}
paths := []string{"CHANGELOG.md", "svc/CHANGELOG.md"}
s.NoError(validateAll(context.Background(), cfg, paths, repo, s.log),
"local dirt (unstaged edits + untracked files) must not cause validate to fail")
}

func (s *ValidateTestSuite) TestRequireChangelogEntry_DisabledByDefault() {
f := s.newRequireEntryFixture()
f.write("svc/foo.go", "package svc\n// updated\n")
Expand Down
Loading
Loading