Skip to content

Commit ae73131

Browse files
authored
fix(changelog): fix KaC ordering, safer delimiter, and per-module changelog paths (#263)
* fix(changelog): fix non-deterministic ordering in keepachangelog formatter * fix(changelog): warn when header-template file is not found A missing header-template file was silently ignored and the default header was used. Now prints a warning to stderr so users knowtheir custom header is not being applied. * fix(changelog): use unit separator as git log field delimiter Replace pipe character (|) with ASCII unit separator (\x1f) in --pretty=format strings and SplitN calls. Prevents commit subjects containing | from corrupting field parsing. * fix(changelog): skip versioned file when all commits are non-conventional When include-non-conventional is false and all commits in the range are non-conventional, do not write a header-only .changes/v*.md file. * feat(changelog): write per-module changelog for independent versioning When workspace.versioning is "independent" and a module path is set, write the unified changelog to {modulePath}/CHANGELOG.md instead of the shared root CHANGELOG.md. Follows the same auto-follow pattern used by tag-manager prefix interpolation. * test(changelog): empty file, module scope, and delimiter tests
1 parent 1f23a2f commit ae73131

13 files changed

Lines changed: 420 additions & 35 deletions

File tree

internal/commands/bump/auto.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -232,7 +232,7 @@ func runSingleModuleAuto(ctx context.Context, cmd *cli.Command, cfg *config.Conf
232232
}
233233

234234
// Execute all post-bump actions
235-
if err := executePostBumpActions(registry, next, current, "auto", path, "", ""); err != nil {
235+
if err := executePostBumpActions(registry, next, current, "auto", path, "", "", false); err != nil {
236236
return err
237237
}
238238

internal/commands/bump/bump_plugins_test.go

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -189,7 +189,7 @@ func TestGenerateChangelogAfterBump(t *testing.T) {
189189
t.Run("nil generator returns nil", func(t *testing.T) {
190190

191191
registry := plugins.NewPluginRegistry()
192-
err := generateChangelogAfterBump(registry, version, prevVersion, "major", "", "")
192+
err := generateChangelogAfterBump(registry, version, prevVersion, "major", "", "", false)
193193
if err != nil {
194194
t.Errorf("expected nil error, got %v", err)
195195
}
@@ -691,7 +691,7 @@ func TestApplyModuleChangelogDir(t *testing.T) {
691691
t.Fatalf("failed to create changelog generator: %v", err)
692692
}
693693

694-
cleanup := applyModuleChangelog(plugin, tt.modulePath, tt.modulePath, "")
694+
cleanup := applyModuleChangelog(plugin, tt.modulePath, tt.modulePath, "", false)
695695

696696
gotCfg := plugin.GetConfig()
697697
if gotCfg.ChangesDir != tt.wantChangesDir {
@@ -728,7 +728,7 @@ func TestApplyModuleChangelogDir_NonPluginType(t *testing.T) {
728728
}
729729

730730
// Should return noop and not panic when cg is not *ChangelogGeneratorPlugin
731-
cleanup := applyModuleChangelog(mock, "cobra", "cobra", "")
731+
cleanup := applyModuleChangelog(mock, "cobra", "cobra", "", false)
732732

733733
// Config should be unchanged because the type assertion fails
734734
gotCfg := mock.GetConfig()
@@ -750,7 +750,7 @@ func TestGenerateChangelogAfterBump_NilGeneratorWithModulePath(t *testing.T) {
750750
prevVersion := semver.SemVersion{Major: 1, Minor: 0, Patch: 0}
751751

752752
// modulePath="cobra" with nil generator should return nil without panic
753-
err := generateChangelogAfterBump(registry, version, prevVersion, "major", "cobra", "cobra")
753+
err := generateChangelogAfterBump(registry, version, prevVersion, "major", "cobra", "cobra", false)
754754
if err != nil {
755755
t.Errorf("expected nil error, got %v", err)
756756
}

internal/commands/bump/common.go

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -82,7 +82,7 @@ func executeSingleModuleBump(
8282
}
8383

8484
// Execute all post-bump actions
85-
if err := executePostBumpActions(registry, result.NewVersion, result.PreviousVersion, params.bumpType, execCtx.Path, "", ""); err != nil {
85+
if err := executePostBumpActions(registry, result.NewVersion, result.PreviousVersion, params.bumpType, execCtx.Path, "", "", false); err != nil {
8686
return err
8787
}
8888

@@ -122,14 +122,15 @@ func executePreBumpValidations(registry *plugins.PluginRegistry, newVersion, pre
122122
// bumpedPath is the .version file path (used to exclude from dep-sync output).
123123
// moduleName identifies the module in changelog headings (empty for single-module).
124124
// modulePath scopes versioned output dirs and git log (empty for root or single-module).
125-
func executePostBumpActions(registry *plugins.PluginRegistry, newVersion, previousVersion semver.SemVersion, bumpType, bumpedPath, moduleName, modulePath string) error {
125+
// independentVersioning scopes the unified changelog to the module directory.
126+
func executePostBumpActions(registry *plugins.PluginRegistry, newVersion, previousVersion semver.SemVersion, bumpType, bumpedPath, moduleName, modulePath string, independentVersioning bool) error {
126127
// Sync dependency files after updating .version
127128
if err := operations.SyncDependencies(registry, newVersion, bumpedPath); err != nil {
128129
return err
129130
}
130131

131132
// Generate changelog entry
132-
if err := generateChangelogAfterBump(registry, newVersion, previousVersion, bumpType, moduleName, modulePath); err != nil {
133+
if err := generateChangelogAfterBump(registry, newVersion, previousVersion, bumpType, moduleName, modulePath, independentVersioning); err != nil {
133134
return err
134135
}
135136

internal/commands/bump/helpers.go

Lines changed: 21 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -258,7 +258,10 @@ func validateDependencyConsistency(registry *plugins.PluginRegistry, version sem
258258
// moduleName is used for unified mode section headers (always set in multi-module mode).
259259
// modulePath scopes versioned output dirs and git log (empty for root module).
260260
// tagPrefix scopes tag resolution (empty for root module).
261-
func applyModuleChangelog(cg changeloggenerator.ChangelogGenerator, moduleName, modulePath, tagPrefix string) func() {
261+
// independentVersioning indicates workspace.versioning == "independent"; when true
262+
// and modulePath is set, the unified changelog is written to {modulePath}/CHANGELOG.md
263+
// instead of the shared root file, and module-name heading prefixes are skipped.
264+
func applyModuleChangelog(cg changeloggenerator.ChangelogGenerator, moduleName, modulePath, tagPrefix string, independentVersioning bool) func() {
262265
noop := func() {}
263266

264267
plugin, ok := cg.(*changeloggenerator.ChangelogGeneratorPlugin)
@@ -268,9 +271,15 @@ func applyModuleChangelog(cg changeloggenerator.ChangelogGenerator, moduleName,
268271

269272
cfg := cg.GetConfig()
270273
originalChangesDir := cfg.ChangesDir
274+
originalChangelogPath := cfg.ChangelogPath
271275

272-
// Always set module name for unified mode heading
273-
if moduleName != "" {
276+
// For independent versioning with a module path, scope the unified changelog
277+
// to the module directory and skip the module-name heading prefix (each module
278+
// has its own file so the prefix is redundant).
279+
perModuleChangelog := independentVersioning && modulePath != ""
280+
281+
// Set module name for unified mode heading (skip when per-module changelog is used)
282+
if moduleName != "" && !perModuleChangelog {
274283
plugin.SetModuleName(moduleName)
275284
}
276285

@@ -281,8 +290,14 @@ func applyModuleChangelog(cg changeloggenerator.ChangelogGenerator, moduleName,
281290
plugin.SetTagPrefix(tagPrefix)
282291
}
283292

293+
// Scope unified changelog path for independent versioning
294+
if perModuleChangelog {
295+
plugin.SetChangelogPath(filepath.Join(modulePath, originalChangelogPath))
296+
}
297+
284298
return func() {
285299
plugin.SetChangesDir(originalChangesDir)
300+
plugin.SetChangelogPath(originalChangelogPath)
286301
plugin.SetModuleName("")
287302
plugin.SetModulePath("")
288303
plugin.SetTagPrefix("")
@@ -307,7 +322,8 @@ func resolveTagPrefix(registry *plugins.PluginRegistry, modulePath string) strin
307322
// Returns nil if changelog generator is not enabled.
308323
// moduleName identifies the module in unified changelog headings (empty for single-module).
309324
// modulePath scopes versioned output dirs and git log (empty for root or single-module).
310-
func generateChangelogAfterBump(registry *plugins.PluginRegistry, version, _ semver.SemVersion, bumpType, moduleName, modulePath string) error {
325+
// independentVersioning scopes the unified changelog to the module directory.
326+
func generateChangelogAfterBump(registry *plugins.PluginRegistry, version, _ semver.SemVersion, bumpType, moduleName, modulePath string, independentVersioning bool) error {
311327
cg := registry.GetChangelogGenerator()
312328
if cg == nil {
313329
return nil
@@ -321,7 +337,7 @@ func generateChangelogAfterBump(registry *plugins.PluginRegistry, version, _ sem
321337
tagPrefix := resolveTagPrefix(registry, modulePath)
322338

323339
// Apply per-module changelog and git scoping
324-
restore := applyModuleChangelog(cg, moduleName, modulePath, tagPrefix)
340+
restore := applyModuleChangelog(cg, moduleName, modulePath, tagPrefix, independentVersioning)
325341
defer restore()
326342

327343
versionStr := "v" + version.String()

internal/commands/bump/multimodule.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -119,7 +119,8 @@ func postBumpForModule(ctx context.Context, result workspace.ExecutionResult, re
119119
effectiveCfg := resolveModuleConfig(cfg, modulePath, result.Module.Dir)
120120

121121
// Post-bump actions (dep-sync, changelog, audit-log)
122-
if err := executePostBumpActions(registry, newVer, oldVer, bumpTypeStr, result.Module.Path, moduleName, modulePath); err != nil {
122+
independentVersioning := cfg != nil && cfg.Workspace != nil && cfg.Workspace.IsIndependentVersioning()
123+
if err := executePostBumpActions(registry, newVer, oldVer, bumpTypeStr, result.Module.Path, moduleName, modulePath, independentVersioning); err != nil {
123124
return fmt.Errorf("module %s: post-bump actions: %w", result.Module.Name, err)
124125
}
125126

internal/plugins/changeloggenerator/formatter_keepachangelog.go

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,9 @@ import (
1414
// - Version header: ## [version] - date (no "v" prefix, brackets around version)
1515
// - Standard sections: Added, Changed, Deprecated, Removed, Fixed, Security
1616
// - No custom icons (strict section names)
17+
//
18+
// Note: Breaking changes are placed in a separate "Breaking Changes" section,
19+
// which is not part of the official KaC specification but is common practice.
1720
type KeepAChangelogFormatter struct {
1821
config *Config
1922
}
@@ -34,7 +37,7 @@ func (f *KeepAChangelogFormatter) FormatChangelog(
3437
fmt.Fprintf(&sb, "## [%s] - %s\n\n", versionNumber, date)
3538

3639
// Regroup commits according to Keep a Changelog sections
37-
sections := f.regroupCommits(grouped)
40+
sections := f.regroupCommits(grouped, sortedKeys)
3841

3942
// Write sections in Keep a Changelog order
4043
sectionOrder := []string{"Breaking Changes", "Added", "Changed", "Deprecated", "Removed", "Fixed", "Security"}
@@ -58,13 +61,17 @@ func (f *KeepAChangelogFormatter) FormatChangelog(
5861
}
5962

6063
// regroupCommits maps conventional commit types to Keep a Changelog sections.
64+
// It iterates using sortedKeys to ensure deterministic ordering when commits
65+
// from different original groups map to the same KaC section.
6166
func (f *KeepAChangelogFormatter) regroupCommits(
6267
grouped map[string][]*GroupedCommit,
68+
sortedKeys []string,
6369
) map[string][]*GroupedCommit {
6470
result := make(map[string][]*GroupedCommit)
6571

66-
// Iterate through all grouped commits and remap them
67-
for _, commits := range grouped {
72+
// Iterate in stable order using sortedKeys instead of ranging the map
73+
for _, key := range sortedKeys {
74+
commits := grouped[key]
6875
for _, commit := range commits {
6976
section := f.mapTypeToSection(commit)
7077
if section != "" {

internal/plugins/changeloggenerator/formatter_test.go

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -717,3 +717,58 @@ func TestGroupedFormatter_MultipleBreakingChanges(t *testing.T) {
717717
t.Error("empty Fixes section should not appear")
718718
}
719719
}
720+
721+
func TestKeepAChangelogFormatter_DeterministicOrdering(t *testing.T) {
722+
723+
cfg := DefaultConfig()
724+
formatter := &KeepAChangelogFormatter{config: cfg}
725+
726+
tests := []struct {
727+
name string
728+
grouped map[string][]*GroupedCommit
729+
sortedKeys []string
730+
}{
731+
{
732+
name: "refactor and perf both map to Changed",
733+
grouped: map[string][]*GroupedCommit{
734+
"Refactors": {
735+
{ParsedCommit: &ParsedCommit{Type: "refactor", Description: "clean up parser", CommitInfo: CommitInfo{ShortHash: "aaa"}}, GroupLabel: "Refactors", GroupOrder: 2},
736+
},
737+
"Performance": {
738+
{ParsedCommit: &ParsedCommit{Type: "perf", Description: "optimize query", CommitInfo: CommitInfo{ShortHash: "bbb"}}, GroupLabel: "Performance", GroupOrder: 4},
739+
},
740+
"Enhancements": {
741+
{ParsedCommit: &ParsedCommit{Type: "feat", Description: "add feature", CommitInfo: CommitInfo{ShortHash: "ccc"}}, GroupLabel: "Enhancements", GroupOrder: 0},
742+
},
743+
},
744+
sortedKeys: []string{"Enhancements", "Refactors", "Performance"},
745+
},
746+
{
747+
name: "style and refactor both map to Changed",
748+
grouped: map[string][]*GroupedCommit{
749+
"Styling": {
750+
{ParsedCommit: &ParsedCommit{Type: "style", Description: "format code", CommitInfo: CommitInfo{ShortHash: "ddd"}}, GroupLabel: "Styling", GroupOrder: 5},
751+
},
752+
"Refactors": {
753+
{ParsedCommit: &ParsedCommit{Type: "refactor", Description: "rename var", CommitInfo: CommitInfo{ShortHash: "eee"}}, GroupLabel: "Refactors", GroupOrder: 2},
754+
},
755+
},
756+
sortedKeys: []string{"Refactors", "Styling"},
757+
},
758+
}
759+
760+
for _, tt := range tests {
761+
t.Run(tt.name, func(t *testing.T) {
762+
763+
var first string
764+
for i := range 20 {
765+
result := formatter.FormatChangelog("v1.0.0", "", tt.grouped, tt.sortedKeys, nil)
766+
if i == 0 {
767+
first = result
768+
} else if result != first {
769+
t.Fatalf("non-deterministic output on iteration %d:\nfirst:\n%s\ngot:\n%s", i, first, result)
770+
}
771+
}
772+
})
773+
}
774+
}

internal/plugins/changeloggenerator/generator.go

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import (
1212
"text/template"
1313

1414
"github.com/indaco/sley/internal/core"
15+
"github.com/indaco/sley/internal/printer"
1516
"github.com/indaco/sley/internal/semver"
1617
)
1718

@@ -114,6 +115,7 @@ func getProviderFromHost(host string) string {
114115
type GenerateResult struct {
115116
Content string
116117
SkippedNonConventional []*ParsedCommit
118+
HasEntries bool // true if at least one commit group was populated
117119
}
118120

119121
// GenerateVersionChangelog generates the changelog content for a version.
@@ -176,6 +178,7 @@ func (g *Generator) GenerateVersionChangelogWithResult(version, previousVersion
176178
return GenerateResult{
177179
Content: sb.String(),
178180
SkippedNonConventional: groupResult.SkippedNonConventional,
181+
HasEntries: len(grouped) > 0,
179182
}
180183
}
181184

@@ -411,7 +414,10 @@ func (g *Generator) WriteUnifiedChangelog(newContent string) error {
411414
func (g *Generator) getDefaultHeader() string {
412415
// Try to read custom header template
413416
if g.config.HeaderTemplate != "" {
414-
if data, err := os.ReadFile(g.config.HeaderTemplate); err == nil {
417+
data, err := os.ReadFile(g.config.HeaderTemplate)
418+
if err != nil {
419+
printer.PrintWarning(fmt.Sprintf("Warning: header-template %q not found, using default header", g.config.HeaderTemplate))
420+
} else {
415421
return strings.TrimSpace(string(data))
416422
}
417423
}

internal/plugins/changeloggenerator/generator_file_test.go

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -557,6 +557,45 @@ func TestGetDefaultHeader_CustomTemplate(t *testing.T) {
557557
}
558558
}
559559

560+
func TestGetDefaultHeader_MissingTemplateFile(t *testing.T) {
561+
562+
tests := []struct {
563+
name string
564+
headerTemplate string
565+
}{
566+
{
567+
name: "non-existent path",
568+
headerTemplate: "/tmp/nonexistent/header-template-xyz.md",
569+
},
570+
{
571+
name: "empty string",
572+
headerTemplate: "",
573+
},
574+
}
575+
576+
for _, tt := range tests {
577+
t.Run(tt.name, func(t *testing.T) {
578+
579+
cfg := DefaultConfig()
580+
cfg.HeaderTemplate = tt.headerTemplate
581+
g, err := NewGenerator(cfg, NewGitOps())
582+
if err != nil {
583+
t.Fatalf("unexpected error: %v", err)
584+
}
585+
586+
header := g.getDefaultHeader()
587+
588+
// Should fall back to default header
589+
if !strings.Contains(header, "Changelog") {
590+
t.Error("expected default header containing 'Changelog'")
591+
}
592+
if !strings.Contains(header, "Semantic Versioning") {
593+
t.Error("expected default header containing 'Semantic Versioning'")
594+
}
595+
})
596+
}
597+
}
598+
560599
func TestInsertAfterHeader(t *testing.T) {
561600

562601
cfg := DefaultConfig()

internal/plugins/changeloggenerator/git.go

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,11 @@ import (
1010
"github.com/indaco/sley/internal/git"
1111
)
1212

13+
// fieldSep is the ASCII unit separator used as a delimiter in git log format strings.
14+
// This character cannot appear in commit messages, avoiding parsing corruption
15+
// that would occur with common characters like pipe (|).
16+
const fieldSep = "\x1f"
17+
1318
// Pre-compiled regexes for URL parsing (compiled once at package init).
1419
var (
1520
// Remote URL formats
@@ -110,8 +115,7 @@ func (g *GitOps) getCommitsWithMeta(since, until string) ([]CommitInfo, error) {
110115
}
111116

112117
revRange := since + ".." + until
113-
// Use a delimiter that's unlikely to appear in commit messages
114-
format := "%H|%h|%s|%an|%ae"
118+
format := "%H" + fieldSep + "%h" + fieldSep + "%s" + fieldSep + "%an" + fieldSep + "%ae"
115119
args := []string{"log", "--pretty=format:" + format, revRange}
116120
// Scope to module path if set (only commits touching this directory)
117121
if g.ModulePath != "" {
@@ -138,7 +142,7 @@ func (g *GitOps) getCommitsWithMeta(since, until string) ([]CommitInfo, error) {
138142

139143
commits := make([]CommitInfo, 0, len(lines))
140144
for _, line := range lines {
141-
parts := strings.SplitN(line, "|", 5)
145+
parts := strings.SplitN(line, fieldSep, 5)
142146
if len(parts) < 5 {
143147
continue // Skip malformed lines
144148
}
@@ -339,7 +343,7 @@ func (g *GitOps) getHistoricalContributors(beforeRef string) (map[string]struct{
339343

340344
// git log --format="%ae|%an" beforeRef
341345
// Returns all author emails and names from the beginning of history up to beforeRef
342-
cmd := g.ExecCommandFn("git", "log", "--format=%ae|%an", beforeRef)
346+
cmd := g.ExecCommandFn("git", "log", "--format=%ae"+fieldSep+"%an", beforeRef)
343347
var stderr bytes.Buffer
344348
cmd.Stderr = &stderr
345349

@@ -359,7 +363,7 @@ func (g *GitOps) getHistoricalContributors(beforeRef string) (map[string]struct{
359363
if line == "" {
360364
continue
361365
}
362-
parts := strings.SplitN(line, "|", 2)
366+
parts := strings.SplitN(line, fieldSep, 2)
363367
if len(parts) < 2 {
364368
continue
365369
}

0 commit comments

Comments
 (0)