Skip to content

Commit d14181c

Browse files
authored
fix: scope commit and changelog inference to module in bump auto --module (#261)
1 parent 6784650 commit d14181c

4 files changed

Lines changed: 136 additions & 36 deletions

File tree

internal/commands/bump/auto.go

Lines changed: 32 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import (
44
"context"
55
"fmt"
66
"os"
7+
"path/filepath"
78

89
"github.com/indaco/sley/internal/clix"
910
"github.com/indaco/sley/internal/config"
@@ -19,7 +20,7 @@ import (
1920

2021
// bumpDeps holds injectable dependencies for auto bump operations.
2122
type bumpDeps struct {
22-
inferFromCommits func(registry *plugins.PluginRegistry, since, until string) string
23+
inferFromCommits func(registry *plugins.PluginRegistry, since, until, tagPrefix, modulePath string) string
2324
inferFromChangelog func(registry *plugins.PluginRegistry) string
2425
newBumper func() semver.VersionBumper
2526
getCommits gitlog.GetCommitsFn
@@ -126,13 +127,27 @@ func runBumpAuto(ctx context.Context, cfg *config.Config, registry *plugins.Plug
126127
}
127128

128129
// Handle multi-module mode
129-
// For auto bump, we need to determine the bump type first
130-
bumpType := determineBumpType(deps, registry, label, disableInfer, since, until)
130+
// For auto bump, we need to determine the bump type first.
131+
// When a single module is targeted (--module), scope commit inference
132+
// to that module's tag prefix and directory so we find the correct
133+
// last tag and only consider relevant commits.
134+
tagPrefix, modulePath := "", ""
135+
if len(execCtx.Modules) == 1 {
136+
mod := execCtx.Modules[0]
137+
modulePath = filepath.Dir(mod.RelPath)
138+
if modulePath == "." {
139+
modulePath = ""
140+
}
141+
tagPrefix = resolveTagPrefix(registry, modulePath)
142+
}
143+
bumpType := determineBumpType(deps, registry, label, disableInfer, since, until, tagPrefix, modulePath)
131144
return runMultiModuleBump(ctx, cmd, cfg, execCtx, registry, deps, bumpType, "", meta, isPreserveMeta)
132145
}
133146

134147
// determineBumpType determines the bump type for multi-module auto bump.
135-
func determineBumpType(deps *bumpDeps, registry *plugins.PluginRegistry, label string, disableInfer bool, since, until string) operations.BumpType {
148+
// tagPrefix and modulePath scope commit inference to a specific module's tags
149+
// and directory (empty for global/all-module inference).
150+
func determineBumpType(deps *bumpDeps, registry *plugins.PluginRegistry, label string, disableInfer bool, since, until, tagPrefix, modulePath string) operations.BumpType {
136151
switch label {
137152
case "patch":
138153
return operations.BumpPatch
@@ -146,7 +161,7 @@ func determineBumpType(deps *bumpDeps, registry *plugins.PluginRegistry, label s
146161
inferred := deps.inferFromChangelog(registry)
147162
if inferred == "" {
148163
// Fall back to commit parser
149-
inferred = deps.inferFromCommits(registry, since, until)
164+
inferred = deps.inferFromCommits(registry, since, until, tagPrefix, modulePath)
150165
}
151166

152167
if inferred != "" {
@@ -262,8 +277,8 @@ func getNextVersion(
262277
// Try changelog parser first if it should take precedence
263278
inferred := deps.inferFromChangelog(registry)
264279
if inferred == "" {
265-
// Fall back to commit parser
266-
inferred = deps.inferFromCommits(registry, since, until)
280+
// Fall back to commit parser (no module scoping in single-module mode)
281+
inferred = deps.inferFromCommits(registry, since, until, "", "")
267282
}
268283

269284
if inferred != "" {
@@ -318,13 +333,21 @@ func promotePreRelease(current semver.SemVersion, preserveMeta bool) semver.SemV
318333
}
319334

320335
// tryInferBumpTypeFromCommitParserPlugin tries to infer bump type from commit messages.
321-
func tryInferBumpTypeFromCommitParserPlugin(registry *plugins.PluginRegistry, since, until string) string {
336+
// tagPrefix scopes tag resolution to tags matching the prefix (e.g. "<module-name>/v").
337+
// modulePath scopes git log to commits touching that directory (e.g. "<module-name>").
338+
// Both are empty for single-module or global inference.
339+
func tryInferBumpTypeFromCommitParserPlugin(registry *plugins.PluginRegistry, since, until, tagPrefix, modulePath string) string {
322340
parser := registry.GetCommitParser()
323341
if parser == nil {
324342
return ""
325343
}
326344

327-
gl := gitlog.NewGitLog()
345+
var gl *gitlog.GitLog
346+
if tagPrefix != "" || modulePath != "" {
347+
gl = gitlog.NewGitLogWithScope(tagPrefix, modulePath)
348+
} else {
349+
gl = gitlog.NewGitLog()
350+
}
328351
commits, err := gl.GetCommits(since, until)
329352
if err != nil {
330353
fmt.Fprintf(os.Stderr, "failed to read commits: %v\n", err)

internal/commands/bump/bump_auto_test.go

Lines changed: 12 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -104,7 +104,7 @@ func TestCLI_BumpAutoCmd_InferredBump(t *testing.T) {
104104
versionPath := testutils.WriteTempVersionFile(t, tmp, "1.2.3")
105105

106106
deps := defaultTestDeps()
107-
deps.inferFromCommits = func(registry *plugins.PluginRegistry, since, until string) string {
107+
deps.inferFromCommits = func(registry *plugins.PluginRegistry, since, until, tagPrefix, modulePath string) string {
108108
return "minor"
109109
}
110110
ctx := testContext(deps)
@@ -199,7 +199,7 @@ func TestCLI_BumpAutoCmd_InferredPromotion(t *testing.T) {
199199
versionPath := testutils.WriteTempVersionFile(t, tmp, "1.2.3-beta.1")
200200

201201
deps := defaultTestDeps()
202-
deps.inferFromCommits = func(registry *plugins.PluginRegistry, since, until string) string {
202+
deps.inferFromCommits = func(registry *plugins.PluginRegistry, since, until, tagPrefix, modulePath string) string {
203203
return "minor"
204204
}
205205
ctx := testContext(deps)
@@ -228,7 +228,7 @@ func TestCLI_BumpAutoCmd_PromotePreReleaseWithPreserveMeta(t *testing.T) {
228228
versionPath := testutils.WriteTempVersionFile(t, tmp, "1.2.3-beta.2+ci.99")
229229

230230
deps := defaultTestDeps()
231-
deps.inferFromCommits = func(registry *plugins.PluginRegistry, since, until string) string {
231+
deps.inferFromCommits = func(registry *plugins.PluginRegistry, since, until, tagPrefix, modulePath string) string {
232232
return "minor" // Force a non-empty inference so that promotePreRelease is called
233233
}
234234
ctx := testContext(deps)
@@ -279,7 +279,7 @@ func TestCLI_BumpAutoCmd_InferredBumpFails(t *testing.T) {
279279
deps.newBumper = func() semver.VersionBumper {
280280
return mockBumper{bumpByLabelErr: fmt.Errorf("forced inferred bump failure")}
281281
}
282-
deps.inferFromCommits = func(registry *plugins.PluginRegistry, since, until string) string {
282+
deps.inferFromCommits = func(registry *plugins.PluginRegistry, since, until, tagPrefix, modulePath string) string {
283283
return "minor"
284284
}
285285
ctx := testContext(deps)
@@ -308,7 +308,7 @@ func TestTryInferBumpTypeFromCommitParserPlugin_GetCommitsError(t *testing.T) {
308308
t.Fatalf("failed to register parser: %v", err)
309309
}
310310
// Without a real git repo, getCommits will fail → should return ""
311-
label := tryInferBumpTypeFromCommitParserPlugin(registry, "", "")
311+
label := tryInferBumpTypeFromCommitParserPlugin(registry, "", "", "", "")
312312
if label != "" {
313313
t.Errorf("expected empty label on gitlog error, got %q", label)
314314
}
@@ -321,15 +321,15 @@ func TestTryInferBumpTypeFromCommitParserPlugin_ParserError(t *testing.T) {
321321
if err := registry.RegisterCommitParser(&parser); err != nil {
322322
t.Fatalf("failed to register parser: %v", err)
323323
}
324-
label := tryInferBumpTypeFromCommitParserPlugin(registry, "", "")
324+
label := tryInferBumpTypeFromCommitParserPlugin(registry, "", "", "", "")
325325
if label != "" {
326326
t.Errorf("expected empty label on error, got %q", label)
327327
}
328328
}
329329

330330
func TestTryInferBumpTypeFromCommitParserPlugin_NoParser(t *testing.T) {
331331
registry := plugins.NewPluginRegistry()
332-
label := tryInferBumpTypeFromCommitParserPlugin(registry, "", "")
332+
label := tryInferBumpTypeFromCommitParserPlugin(registry, "", "", "", "")
333333
if label != "" {
334334
t.Errorf("expected empty label when no parser, got %q", label)
335335
}
@@ -547,11 +547,13 @@ func TestDetermineBumpType(t *testing.T) {
547547
t.Run(tt.name, func(t *testing.T) {
548548
deps := &bumpDeps{
549549
inferFromChangelog: func(registry *plugins.PluginRegistry) string { return tt.mockChangelog },
550-
inferFromCommits: func(registry *plugins.PluginRegistry, since, until string) string { return tt.mockCommit },
550+
inferFromCommits: func(registry *plugins.PluginRegistry, since, until, tagPrefix, modulePath string) string {
551+
return tt.mockCommit
552+
},
551553
}
552554

553555
registry := plugins.NewPluginRegistry()
554-
result := determineBumpType(deps, registry, tt.label, tt.disableInfer, "", "")
556+
result := determineBumpType(deps, registry, tt.label, tt.disableInfer, "", "", "", "")
555557

556558
if string(result) != tt.expected {
557559
t.Errorf("expected %q, got %q", tt.expected, string(result))
@@ -836,7 +838,7 @@ func TestBumpAuto_InferredMinorBump_WithTagManager(t *testing.T) {
836838
testutils.WriteTempVersionFile(t, tmpDir, "1.0.0")
837839

838840
deps := defaultTestDeps()
839-
deps.inferFromCommits = func(registry *plugins.PluginRegistry, since, until string) string {
841+
deps.inferFromCommits = func(registry *plugins.PluginRegistry, since, until, tagPrefix, modulePath string) string {
840842
return "minor"
841843
}
842844
ctx := testContext(deps)

internal/commands/bump/helpers.go

Lines changed: 37 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -297,7 +297,7 @@ func resolveTagPrefix(registry *plugins.PluginRegistry, modulePath string) strin
297297
return ""
298298
}
299299
tm := registry.GetTagManager()
300-
if tm == nil || !tm.IsAutoCreateEnabled() {
300+
if tm == nil || !tm.GetConfig().Enabled {
301301
return ""
302302
}
303303
return tagmanager.InterpolatePrefix(tm.GetConfig().Prefix, modulePath)
@@ -326,30 +326,51 @@ func generateChangelogAfterBump(registry *plugins.PluginRegistry, version, _ sem
326326

327327
versionStr := "v" + version.String()
328328

329-
// Use actual git tag for commit range, not version file content
330-
// The version file may contain pre-release/metadata that doesn't match a real tag
331-
prevVersionStr, err := changeloggenerator.GetLatestTag()
332-
if err != nil {
333-
// If no tags exist, generate from all commits
334-
prevVersionStr = ""
335-
}
336-
337-
if err := cg.GenerateForVersion(versionStr, prevVersionStr, bumpType); err != nil {
329+
// Pass empty previousVersion so GenerateForVersion resolves the commit
330+
// range internally via the plugin's scoped GitOps (which respects TagPrefix
331+
// and ModulePath set by applyModuleChangelog above).
332+
if err := cg.GenerateForVersion(versionStr, "", bumpType); err != nil {
338333
return fmt.Errorf("failed to generate changelog: %w", err)
339334
}
340335

341-
cfg := cg.GetConfig()
336+
printChangelogStatus(cg.GetConfig(), versionStr)
337+
return nil
338+
}
339+
340+
// printChangelogStatus prints a message about which changelog files were actually
341+
// created on disk. Only prints for files that exist, preventing false positives
342+
// when GenerateForVersion returns nil without writing (e.g. zero commits).
343+
func printChangelogStatus(cfg *changeloggenerator.Config, versionStr string) {
344+
versionedPath := filepath.Join(cfg.ChangesDir, versionStr+".md")
345+
versionedExists := fileExists(versionedPath)
346+
unifiedExists := fileExists(cfg.ChangelogPath)
347+
342348
switch cfg.Mode {
343349
case "versioned":
344-
printer.PrintFaint(fmt.Sprintf("Generated changelog: %s", printer.Info(fmt.Sprintf("%s/%s.md", cfg.ChangesDir, versionStr))))
350+
if versionedExists {
351+
printer.PrintFaint(fmt.Sprintf("Generated changelog: %s", printer.Info(versionedPath)))
352+
}
345353
case "unified":
346-
printer.PrintFaint(fmt.Sprintf("Updated changelog: %s", printer.Info(cfg.ChangelogPath)))
354+
if unifiedExists {
355+
printer.PrintFaint(fmt.Sprintf("Updated changelog: %s", printer.Info(cfg.ChangelogPath)))
356+
}
347357
case "both":
348-
printer.PrintFaint(fmt.Sprintf("Generated changelog: %s and %s",
349-
printer.Info(fmt.Sprintf("%s/%s.md", cfg.ChangesDir, versionStr)), printer.Info(cfg.ChangelogPath)))
358+
switch {
359+
case versionedExists && unifiedExists:
360+
printer.PrintFaint(fmt.Sprintf("Generated changelog: %s and %s",
361+
printer.Info(versionedPath), printer.Info(cfg.ChangelogPath)))
362+
case versionedExists:
363+
printer.PrintFaint(fmt.Sprintf("Generated changelog: %s", printer.Info(versionedPath)))
364+
case unifiedExists:
365+
printer.PrintFaint(fmt.Sprintf("Updated changelog: %s", printer.Info(cfg.ChangelogPath)))
366+
}
350367
}
368+
}
351369

352-
return nil
370+
// fileExists returns true if the path exists on disk.
371+
func fileExists(path string) bool {
372+
_, err := os.Stat(path)
373+
return err == nil
353374
}
354375

355376
// recordAuditLogEntry records the version bump to the audit log if enabled.

internal/plugins/commitparser/gitlog/gitlog.go

Lines changed: 55 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,13 +34,30 @@ type ExecCommandFunc func(name string, arg ...string) *exec.Cmd
3434
// GitLog retrieves commit messages from a git repository.
3535
type GitLog struct {
3636
ExecCommandFn ExecCommandFunc
37+
// TagPrefix scopes tag resolution to tags matching this prefix.
38+
// Empty means use the latest tag globally.
39+
TagPrefix string
40+
// ModulePath scopes git log to commits touching this directory.
41+
// Empty means no path filtering (all commits).
42+
ModulePath string
3743
}
3844

3945
// NewGitLog creates a GitLog with the real exec.Command implementation.
4046
func NewGitLog() *GitLog {
4147
return &GitLog{ExecCommandFn: exec.Command}
4248
}
4349

50+
// NewGitLogWithScope creates a GitLog scoped to a specific module.
51+
// tagPrefix filters tag resolution (e.g. "<module-name>/v") and modulePath
52+
// scopes git log to commits touching that directory (e.g. "<module-name>").
53+
func NewGitLogWithScope(tagPrefix, modulePath string) *GitLog {
54+
return &GitLog{
55+
ExecCommandFn: exec.Command,
56+
TagPrefix: tagPrefix,
57+
ModulePath: modulePath,
58+
}
59+
}
60+
4461
// GetCommitsFn is the function type for GetCommits (used for dependency injection).
4562
type GetCommitsFn func(since, until string) ([]string, error)
4663

@@ -75,7 +92,12 @@ func (g *GitLog) GetCommits(since string, until string) ([]string, error) {
7592
}
7693

7794
revRange := since + ".." + until
78-
cmd := g.ExecCommandFn("git", "log", "--pretty=format:%s", revRange)
95+
args := []string{"log", "--pretty=format:%s", revRange}
96+
// Scope to module path if set (only commits touching this directory)
97+
if g.ModulePath != "" {
98+
args = append(args, "--", g.ModulePath)
99+
}
100+
cmd := g.ExecCommandFn("git", args...)
79101

80102
var stderr bytes.Buffer
81103
cmd.Stderr = &stderr
@@ -97,6 +119,12 @@ func (g *GitLog) GetCommits(since string, until string) ([]string, error) {
97119
}
98120

99121
func (g *GitLog) getLastTag() (string, error) {
122+
// When a tag prefix is set, use git tag --list with version sorting
123+
// to find the latest tag matching the prefix (e.g. "<module-name>/v*").
124+
if g.TagPrefix != "" {
125+
return g.getLastTagWithPrefix(g.TagPrefix)
126+
}
127+
100128
cmd := g.ExecCommandFn("git", "describe", "--tags", "--abbrev=0")
101129
var stderr bytes.Buffer
102130
cmd.Stderr = &stderr
@@ -117,3 +145,29 @@ func (g *GitLog) getLastTag() (string, error) {
117145

118146
return tag, nil
119147
}
148+
149+
// getLastTagWithPrefix returns the most recent tag matching the given prefix.
150+
// Uses git tag --list with version sorting to find the latest match.
151+
func (g *GitLog) getLastTagWithPrefix(prefix string) (string, error) {
152+
cmd := g.ExecCommandFn("git", "tag", "--list", prefix+"*", "--sort=-v:refname")
153+
var stderr bytes.Buffer
154+
cmd.Stderr = &stderr
155+
156+
out, err := cmd.Output()
157+
if err != nil {
158+
stderrMsg := strings.TrimSpace(stderr.String())
159+
if stderrMsg != "" {
160+
return "", fmt.Errorf("git tag list failed: %s: %w", stderrMsg, err)
161+
}
162+
return "", fmt.Errorf("git tag list failed: %w", err)
163+
}
164+
165+
output := strings.TrimSpace(string(out))
166+
if output == "" {
167+
return "", fmt.Errorf("no tags found matching prefix %q", prefix)
168+
}
169+
170+
// First line is the latest tag (sorted by version descending)
171+
tag, _, _ := strings.Cut(output, "\n")
172+
return strings.TrimSpace(tag), nil
173+
}

0 commit comments

Comments
 (0)