diff --git a/cmd/main.go b/cmd/main.go
index 59790dc..d5c2d19 100644
--- a/cmd/main.go
+++ b/cmd/main.go
@@ -158,7 +158,7 @@ func initializeApp() (*cli.Command, error) {
return issueService, nil
}
commands := []*cli.Command{
- suggests_commits.NewSuggestCommandFactory(commitService, commitHandler).CreateCommand(translations, cfgApp),
+ suggests_commits.NewSuggestCommandFactory(commitService, commitHandler, gitService).CreateCommand(translations, cfgApp),
issues.NewIssuesCommandFactory(
issueProvider,
templateService,
diff --git a/internal/commands/release/create.go b/internal/commands/release/create.go
index 4609d3d..d7e2e72 100644
--- a/internal/commands/release/create.go
+++ b/internal/commands/release/create.go
@@ -86,6 +86,13 @@ func createReleaseAction(releaseSvc releaseService, trans *i18n.Translations, re
fmt.Println(trans.GetMessage("release.creating", 0, nil))
fmt.Println()
+ if err := releaseSvc.ValidateMainBranch(ctx); err != nil {
+ log.Error("branch validation failed",
+ "error", err,
+ )
+ return fmt.Errorf("%s", trans.GetMessage("release.error_invalid_branch", 0, struct{ Error string }{err.Error()}))
+ }
+
release, err := releaseSvc.AnalyzeNextRelease(ctx)
if err != nil {
log.Error("failed to analyze next release",
@@ -139,7 +146,7 @@ func createReleaseAction(releaseSvc releaseService, trans *i18n.Translations, re
sVersion := ui.NewSmartSpinner(trans.GetMessage("release.app_version_update_started", 0, struct{ Version string }{release.Version}))
sVersion.Start()
- if err := releaseSvc.UpdateAppVersion(release.Version); err != nil {
+ if err := releaseSvc.UpdateAppVersion(ctx, release.Version); err != nil {
sVersion.Error(trans.GetMessage("release.error_updating_app_version", 0, struct{ Error string }{err.Error()}))
return fmt.Errorf("error updating app version: %w", err)
}
diff --git a/internal/commands/release/create_test.go b/internal/commands/release/create_test.go
index 7cd83cc..20dbc41 100644
--- a/internal/commands/release/create_test.go
+++ b/internal/commands/release/create_test.go
@@ -7,11 +7,11 @@ import (
"errors"
"testing"
- "github.com/thomas-vilte/matecommit/internal/models"
- "github.com/thomas-vilte/matecommit/internal/i18n"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
"github.com/stretchr/testify/require"
+ "github.com/thomas-vilte/matecommit/internal/i18n"
+ "github.com/thomas-vilte/matecommit/internal/models"
"github.com/urfave/cli/v3"
)
@@ -63,6 +63,7 @@ func TestCreateCommand_Success(t *testing.T) {
}
mockService.On("AnalyzeNextRelease", mock.Anything).Return(release, nil)
+ mockService.On("ValidateMainBranch", mock.Anything, mock.Anything).Return(nil)
mockService.On("EnrichReleaseContext", mock.Anything, mock.Anything).Return(nil)
mockService.On("GenerateReleaseNotes", mock.Anything, release).Return(notes, nil)
mockService.On("CreateTag", mock.Anything, "v1.1.0", "Release v1.1.0\n\nSummary of release").Return(nil)
@@ -85,6 +86,7 @@ func TestCreateCommand_WithVersionOverride(t *testing.T) {
Title: "Release v2.0.0",
}
+ mockService.On("ValidateMainBranch", mock.Anything, mock.Anything).Return(nil)
mockService.On("AnalyzeNextRelease", mock.Anything).Return(release, nil)
mockService.On("EnrichReleaseContext", mock.Anything, mock.Anything).Return(nil)
mockService.On("GenerateReleaseNotes", mock.Anything, mock.MatchedBy(func(r *models.Release) bool {
@@ -103,6 +105,7 @@ func TestCreateCommand_AutoConfirm(t *testing.T) {
release := &models.Release{Version: "v1.0.1"}
notes := &models.ReleaseNotes{Title: "Fix"}
+ mockService.On("ValidateMainBranch", mock.Anything, mock.Anything).Return(nil)
mockService.On("AnalyzeNextRelease", mock.Anything).Return(release, nil)
mockService.On("EnrichReleaseContext", mock.Anything, mock.Anything).Return(nil)
mockService.On("GenerateReleaseNotes", mock.Anything, release).Return(notes, nil)
@@ -117,6 +120,7 @@ func TestCreateCommand_Cancelled(t *testing.T) {
release := &models.Release{Version: "v1.0.1"}
notes := &models.ReleaseNotes{Title: "Fix"}
+ mockService.On("ValidateMainBranch", mock.Anything, mock.Anything).Return(nil)
mockService.On("AnalyzeNextRelease", mock.Anything).Return(release, nil)
mockService.On("EnrichReleaseContext", mock.Anything, mock.Anything).Return(nil)
mockService.On("GenerateReleaseNotes", mock.Anything, release).Return(notes, nil)
@@ -128,6 +132,7 @@ func TestCreateCommand_Cancelled(t *testing.T) {
func TestCreateCommand_AnalyzeError(t *testing.T) {
mockService := new(MockReleaseService)
+ mockService.On("ValidateMainBranch", mock.Anything, mock.Anything).Return(nil)
mockService.On("AnalyzeNextRelease", mock.Anything).Return((*models.Release)(nil), errors.New("git error"))
mockService.On("EnrichReleaseContext", mock.Anything, mock.Anything).Return(nil)
@@ -140,6 +145,7 @@ func TestCreateCommand_AnalyzeError(t *testing.T) {
func TestCreateCommand_GenerateNotesError(t *testing.T) {
mockService := new(MockReleaseService)
release := &models.Release{Version: "v1.0.0"}
+ mockService.On("ValidateMainBranch", mock.Anything, mock.Anything).Return(nil)
mockService.On("AnalyzeNextRelease", mock.Anything).Return(release, nil)
mockService.On("EnrichReleaseContext", mock.Anything, mock.Anything).Return(nil)
mockService.On("GenerateReleaseNotes", mock.Anything, release).Return((*models.ReleaseNotes)(nil), errors.New("ai error"))
@@ -156,6 +162,7 @@ func TestCreateCommand_CreateTagError(t *testing.T) {
release := &models.Release{Version: "v1.0.0"}
notes := &models.ReleaseNotes{Title: "Title"}
+ mockService.On("ValidateMainBranch", mock.Anything, mock.Anything).Return(nil)
mockService.On("AnalyzeNextRelease", mock.Anything).Return(release, nil)
mockService.On("EnrichReleaseContext", mock.Anything, mock.Anything).Return(nil)
mockService.On("GenerateReleaseNotes", mock.Anything, release).Return(notes, nil)
@@ -173,12 +180,12 @@ func TestCreateCommand_WithPublish(t *testing.T) {
release := &models.Release{Version: "v1.0.0"}
notes := &models.ReleaseNotes{Title: "Title"}
+ mockService.On("ValidateMainBranch", mock.Anything, mock.Anything).Return(nil)
mockService.On("AnalyzeNextRelease", mock.Anything).Return(release, nil)
mockService.On("EnrichReleaseContext", mock.Anything, mock.Anything).Return(nil)
mockService.On("GenerateReleaseNotes", mock.Anything, release).Return(notes, nil)
mockService.On("CreateTag", mock.Anything, "v1.0.0", mock.Anything).Return(nil)
- // Expect PublishRelease with buildBinaries=true (default)
mockService.On("PublishRelease", mock.Anything, release, notes, false, true).Return(nil)
err := runCreateTest(t, "y\n", []string{"--publish"}, mockService)
@@ -192,12 +199,12 @@ func TestCreateCommand_WithPublishDraft(t *testing.T) {
release := &models.Release{Version: "v1.0.0"}
notes := &models.ReleaseNotes{Title: "Title"}
+ mockService.On("ValidateMainBranch", mock.Anything, mock.Anything).Return(nil)
mockService.On("AnalyzeNextRelease", mock.Anything).Return(release, nil)
mockService.On("EnrichReleaseContext", mock.Anything, mock.Anything).Return(nil)
mockService.On("GenerateReleaseNotes", mock.Anything, release).Return(notes, nil)
mockService.On("CreateTag", mock.Anything, "v1.0.0", mock.Anything).Return(nil)
- // Expect PublishRelease with draft=true and buildBinaries=true (default)
mockService.On("PublishRelease", mock.Anything, release, notes, true, true).Return(nil)
err := runCreateTest(t, "y\n", []string{"--publish", "--draft"}, mockService)
@@ -211,12 +218,12 @@ func TestCreateCommand_PublishError(t *testing.T) {
release := &models.Release{Version: "v1.0.0"}
notes := &models.ReleaseNotes{Title: "Title"}
+ mockService.On("ValidateMainBranch", mock.Anything, mock.Anything).Return(nil)
mockService.On("AnalyzeNextRelease", mock.Anything).Return(release, nil)
mockService.On("EnrichReleaseContext", mock.Anything, mock.Anything).Return(nil)
mockService.On("GenerateReleaseNotes", mock.Anything, release).Return(notes, nil)
mockService.On("CreateTag", mock.Anything, "v1.0.0", mock.Anything).Return(nil)
- // Expect PublishRelease error
mockService.On("PublishRelease", mock.Anything, release, notes, false, true).Return(errors.New("publish error"))
err := runCreateTest(t, "y\n", []string{"--publish"}, mockService)
@@ -229,12 +236,13 @@ func TestCreateCommand_WithChangelog(t *testing.T) {
release := &models.Release{Version: "v1.0.0"}
notes := &models.ReleaseNotes{Title: "Title"}
+ mockService.On("ValidateMainBranch", mock.Anything, mock.Anything).Return(nil)
mockService.On("AnalyzeNextRelease", mock.Anything).Return(release, nil)
mockService.On("EnrichReleaseContext", mock.Anything, mock.Anything).Return(nil)
mockService.On("GenerateReleaseNotes", mock.Anything, release).Return(notes, nil)
mockService.On("UpdateLocalChangelog", release, notes).Return(nil)
- mockService.On("UpdateAppVersion", "v1.0.0").Return(nil)
+ mockService.On("UpdateAppVersion", mock.Anything, "v1.0.0").Return(nil)
mockService.On("CommitChangelog", mock.Anything, "v1.0.0").Return(nil)
mockService.On("PushChanges", mock.Anything).Return(nil)
diff --git a/internal/commands/release/generate.go b/internal/commands/release/generate.go
index 5c76247..8a1f43a 100644
--- a/internal/commands/release/generate.go
+++ b/internal/commands/release/generate.go
@@ -50,6 +50,12 @@ func generateReleaseAction(releaseSvc releaseService, trans *i18n.Translations)
fmt.Println(trans.GetMessage("release.generating", 0, nil))
fmt.Println()
+ if err := releaseSvc.ValidateMainBranch(ctx); err != nil {
+ log.Error("branch validation failed",
+ "error", err)
+ return fmt.Errorf("%s", trans.GetMessage("release.error_invalid_branch", 0, struct{ Error string }{err.Error()}))
+ }
+
release, err := releaseSvc.AnalyzeNextRelease(ctx)
if err != nil {
log.Error("failed to analyze next release",
diff --git a/internal/commands/release/generate_test.go b/internal/commands/release/generate_test.go
index 62cc64c..25a7813 100644
--- a/internal/commands/release/generate_test.go
+++ b/internal/commands/release/generate_test.go
@@ -7,11 +7,11 @@ import (
"path/filepath"
"testing"
- "github.com/thomas-vilte/matecommit/internal/models"
- "github.com/thomas-vilte/matecommit/internal/i18n"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
"github.com/stretchr/testify/require"
+ "github.com/thomas-vilte/matecommit/internal/i18n"
+ "github.com/thomas-vilte/matecommit/internal/models"
"github.com/urfave/cli/v3"
)
@@ -55,6 +55,7 @@ func TestGenerateCommand_Success(t *testing.T) {
Highlights: []string{"Highlight 1"},
}
+ mockService.On("ValidateMainBranch", mock.Anything, mock.Anything).Return(nil)
mockService.On("AnalyzeNextRelease", mock.Anything).Return(release, nil)
mockService.On("EnrichReleaseContext", mock.Anything, mock.Anything).Return(nil)
mockService.On("GenerateReleaseNotes", mock.Anything, release).Return(notes, nil)
@@ -75,6 +76,7 @@ func TestGenerateCommand_Success(t *testing.T) {
func TestGenerateCommand_AnalyzeError(t *testing.T) {
mockService := new(MockReleaseService)
+ mockService.On("ValidateMainBranch", mock.Anything, mock.Anything).Return(nil)
mockService.On("AnalyzeNextRelease", mock.Anything).Return((*models.Release)(nil), errors.New("git error"))
mockService.On("EnrichReleaseContext", mock.Anything, mock.Anything).Return(nil)
@@ -88,6 +90,7 @@ func TestGenerateCommand_AnalyzeError(t *testing.T) {
func TestGenerateCommand_GenerateError(t *testing.T) {
mockService := new(MockReleaseService)
release := &models.Release{Version: "v1.0.0"}
+ mockService.On("ValidateMainBranch", mock.Anything, mock.Anything).Return(nil)
mockService.On("AnalyzeNextRelease", mock.Anything).Return(release, nil)
mockService.On("EnrichReleaseContext", mock.Anything, mock.Anything).Return(nil)
mockService.On("GenerateReleaseNotes", mock.Anything, release).Return((*models.ReleaseNotes)(nil), errors.New("ai error"))
@@ -104,6 +107,7 @@ func TestGenerateCommand_WriteError(t *testing.T) {
release := &models.Release{Version: "v1.0.0"}
notes := &models.ReleaseNotes{Title: "Title"}
+ mockService.On("ValidateMainBranch", mock.Anything, mock.Anything).Return(nil)
mockService.On("AnalyzeNextRelease", mock.Anything).Return(release, nil)
mockService.On("EnrichReleaseContext", mock.Anything, mock.Anything).Return(nil)
mockService.On("GenerateReleaseNotes", mock.Anything, release).Return(notes, nil)
diff --git a/internal/commands/release/mocks.go b/internal/commands/release/mocks.go
index c46cce9..27eb85e 100644
--- a/internal/commands/release/mocks.go
+++ b/internal/commands/release/mocks.go
@@ -3,8 +3,8 @@ package release
import (
"context"
- "github.com/thomas-vilte/matecommit/internal/models"
"github.com/stretchr/testify/mock"
+ "github.com/thomas-vilte/matecommit/internal/models"
)
type MockReleaseService struct {
@@ -75,8 +75,8 @@ func (m *MockReleaseService) PushChanges(ctx context.Context) error {
return args.Error(0)
}
-func (m *MockReleaseService) UpdateAppVersion(version string) error {
- args := m.Called(version)
+func (m *MockReleaseService) UpdateAppVersion(ctx context.Context, version string) error {
+ args := m.Called(ctx, version)
return args.Error(0)
}
@@ -169,3 +169,23 @@ func (m *MockGitService) GetRecentCommitMessages(ctx context.Context, count int)
args := m.Called(ctx, count)
return args.Get(0).([]string), args.Error(1)
}
+
+func (m *MockReleaseService) ValidateMainBranch(ctx context.Context) error {
+ args := m.Called(ctx)
+ return args.Error(0)
+}
+
+func (m *MockGitService) FetchTags(ctx context.Context) error {
+ args := m.Called(ctx)
+ return args.Error(0)
+}
+
+func (m *MockGitService) ValidateGitConfig(ctx context.Context) error {
+ args := m.Called(ctx)
+ return args.Error(0)
+}
+
+func (m *MockGitService) ValidateTagExists(ctx context.Context, tag string) error {
+ args := m.Called(ctx, tag)
+ return args.Error(0)
+}
diff --git a/internal/commands/release/preview.go b/internal/commands/release/preview.go
index a08da8d..51b270d 100644
--- a/internal/commands/release/preview.go
+++ b/internal/commands/release/preview.go
@@ -38,6 +38,12 @@ func previewReleaseAction(releaseSvc releaseService, trans *i18n.Translations) c
fmt.Println(trans.GetMessage("release.analyzing", 0, nil))
fmt.Println()
+ if err := releaseSvc.ValidateMainBranch(ctx); err != nil {
+ log.Error("branch validation failed",
+ "error", err)
+ return fmt.Errorf("%s", trans.GetMessage("release.error_invalid_branch", 0, struct{ Error string }{err.Error()}))
+ }
+
release, err := releaseSvc.AnalyzeNextRelease(ctx)
if err != nil {
log.Error("failed to analyze next release",
diff --git a/internal/commands/release/preview_test.go b/internal/commands/release/preview_test.go
index d407720..94464a5 100644
--- a/internal/commands/release/preview_test.go
+++ b/internal/commands/release/preview_test.go
@@ -5,11 +5,11 @@ import (
"errors"
"testing"
- "github.com/thomas-vilte/matecommit/internal/models"
- "github.com/thomas-vilte/matecommit/internal/i18n"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
"github.com/stretchr/testify/require"
+ "github.com/thomas-vilte/matecommit/internal/i18n"
+ "github.com/thomas-vilte/matecommit/internal/models"
"github.com/urfave/cli/v3"
)
@@ -50,6 +50,7 @@ func TestPreviewCommand_Success(t *testing.T) {
Highlights: []string{"highlight"},
}
+ mockService.On("ValidateMainBranch", mock.Anything, mock.Anything).Return(nil)
mockService.On("AnalyzeNextRelease", mock.Anything).Return(release, nil)
mockService.On("EnrichReleaseContext", mock.Anything, mock.Anything).Return(nil)
mockService.On("GenerateReleaseNotes", mock.Anything, release).Return(notes, nil)
@@ -62,6 +63,7 @@ func TestPreviewCommand_Success(t *testing.T) {
func TestPreviewCommand_AnalyzeError(t *testing.T) {
mockService := new(MockReleaseService)
+ mockService.On("ValidateMainBranch", mock.Anything, mock.Anything).Return(nil)
mockService.On("AnalyzeNextRelease", mock.Anything).Return((*models.Release)(nil), errors.New("git error"))
mockService.On("EnrichReleaseContext", mock.Anything, mock.Anything).Return(nil)
@@ -75,6 +77,7 @@ func TestPreviewCommand_AnalyzeError(t *testing.T) {
func TestPreviewCommand_GenerateError(t *testing.T) {
mockService := new(MockReleaseService)
release := &models.Release{Version: "v1.0.0"}
+ mockService.On("ValidateMainBranch", mock.Anything, mock.Anything).Return(nil)
mockService.On("AnalyzeNextRelease", mock.Anything).Return(release, nil)
mockService.On("EnrichReleaseContext", mock.Anything, mock.Anything).Return(nil)
mockService.On("GenerateReleaseNotes", mock.Anything, release).Return((*models.ReleaseNotes)(nil), errors.New("ai error"))
diff --git a/internal/commands/release/publish.go b/internal/commands/release/publish.go
index e57a1e5..58a6c60 100644
--- a/internal/commands/release/publish.go
+++ b/internal/commands/release/publish.go
@@ -59,7 +59,14 @@ func publishReleaseAction(releaseSvc releaseService,
log.Info("executing release publish command",
"version", version,
"draft", draft,
- "build_binaries", buildBinaries)
+ "build_binaries", buildBinaries,
+ )
+
+ if err := releaseSvc.ValidateMainBranch(ctx); err != nil {
+ log.Error("branch validation failed",
+ "error", err)
+ return fmt.Errorf("%s", trans.GetMessage("release.error_invalid_branch", 0, struct{ Error string }{err.Error()}))
+ }
release, err := releaseSvc.AnalyzeNextRelease(ctx)
if err != nil {
diff --git a/internal/commands/release/publish_test.go b/internal/commands/release/publish_test.go
index af65e98..e223e37 100644
--- a/internal/commands/release/publish_test.go
+++ b/internal/commands/release/publish_test.go
@@ -5,11 +5,11 @@ import (
"errors"
"testing"
- "github.com/thomas-vilte/matecommit/internal/models"
- "github.com/thomas-vilte/matecommit/internal/i18n"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
"github.com/stretchr/testify/require"
+ "github.com/thomas-vilte/matecommit/internal/i18n"
+ "github.com/thomas-vilte/matecommit/internal/models"
"github.com/urfave/cli/v3"
)
@@ -53,6 +53,7 @@ func TestPublishCommand_Success(t *testing.T) {
Summary: "Major release",
}
+ mockService.On("ValidateMainBranch", mock.Anything, mock.Anything).Return(nil)
mockService.On("AnalyzeNextRelease", mock.Anything).Return(release, nil)
mockService.On("EnrichReleaseContext", mock.Anything, mock.Anything).Return(nil)
mockService.On("GenerateReleaseNotes", mock.Anything, release).Return(notes, nil)
@@ -78,6 +79,7 @@ func TestPublishCommand_WithDraftFlag(t *testing.T) {
Summary: "Major release",
}
+ mockService.On("ValidateMainBranch", mock.Anything, mock.Anything).Return(nil)
mockService.On("AnalyzeNextRelease", mock.Anything).Return(release, nil)
mockService.On("EnrichReleaseContext", mock.Anything, mock.Anything).Return(nil)
mockService.On("GenerateReleaseNotes", mock.Anything, release).Return(notes, nil)
@@ -103,6 +105,7 @@ func TestPublishCommand_WithVersionOverride(t *testing.T) {
Summary: "Major release",
}
+ mockService.On("ValidateMainBranch", mock.Anything, mock.Anything).Return(nil)
mockService.On("AnalyzeNextRelease", mock.Anything).Return(release, nil)
mockService.On("EnrichReleaseContext", mock.Anything, mock.Anything).Return(nil)
mockService.On("GenerateReleaseNotes", mock.Anything, mock.MatchedBy(func(r *models.Release) bool {
@@ -121,6 +124,7 @@ func TestPublishCommand_WithVersionOverride(t *testing.T) {
func TestPublishCommand_AnalyzeError(t *testing.T) {
mockService := new(MockReleaseService)
+ mockService.On("ValidateMainBranch", mock.Anything, mock.Anything).Return(nil)
mockService.On("AnalyzeNextRelease", mock.Anything).Return((*models.Release)(nil), errors.New("analyze failed"))
err := runPublishTest(t, []string{}, mockService)
@@ -139,6 +143,7 @@ func TestPublishCommand_GenerateNotesError(t *testing.T) {
VersionBump: models.MinorBump,
}
+ mockService.On("ValidateMainBranch", mock.Anything, mock.Anything).Return(nil)
mockService.On("AnalyzeNextRelease", mock.Anything).Return(release, nil)
mockService.On("EnrichReleaseContext", mock.Anything, mock.Anything).Return(nil)
mockService.On("GenerateReleaseNotes", mock.Anything, release).Return((*models.ReleaseNotes)(nil), errors.New("generate failed"))
@@ -164,6 +169,7 @@ func TestPublishCommand_PublishError(t *testing.T) {
Summary: "Major release",
}
+ mockService.On("ValidateMainBranch", mock.Anything, mock.Anything).Return(nil)
mockService.On("AnalyzeNextRelease", mock.Anything).Return(release, nil)
mockService.On("EnrichReleaseContext", mock.Anything, mock.Anything).Return(nil)
mockService.On("GenerateReleaseNotes", mock.Anything, release).Return(notes, nil)
diff --git a/internal/commands/release/release.go b/internal/commands/release/release.go
index a5da799..f955206 100644
--- a/internal/commands/release/release.go
+++ b/internal/commands/release/release.go
@@ -28,7 +28,8 @@ type releaseService interface {
UpdateLocalChangelog(release *models.Release, notes *models.ReleaseNotes) error
CommitChangelog(ctx context.Context, version string) error
PushChanges(ctx context.Context) error
- UpdateAppVersion(version string) error
+ UpdateAppVersion(ctx context.Context, version string) error
+ ValidateMainBranch(ctx context.Context) error
}
// gitService is a minimal interface for testing purposes
@@ -48,6 +49,9 @@ type gitService interface {
HasStagedChanges(ctx context.Context) bool
CreateCommit(ctx context.Context, message string) error
Push(ctx context.Context) error
+ FetchTags(ctx context.Context) error
+ ValidateGitConfig(ctx context.Context) error
+ ValidateTagExists(ctx context.Context, tag string) error
}
type ReleaseCommandFactory struct {
diff --git a/internal/commands/suggests_commits/suggests_commits.go b/internal/commands/suggests_commits/suggests_commits.go
index 749d3e1..c38af5b 100644
--- a/internal/commands/suggests_commits/suggests_commits.go
+++ b/internal/commands/suggests_commits/suggests_commits.go
@@ -8,7 +8,6 @@ import (
"github.com/thomas-vilte/matecommit/internal/commands/completion_helper"
"github.com/thomas-vilte/matecommit/internal/config"
- "github.com/thomas-vilte/matecommit/internal/git"
"github.com/thomas-vilte/matecommit/internal/i18n"
"github.com/thomas-vilte/matecommit/internal/logger"
"github.com/thomas-vilte/matecommit/internal/models"
@@ -26,15 +25,21 @@ type commitHandler interface {
HandleSuggestions(ctx context.Context, suggestions []models.CommitSuggestion) error
}
+type gitService interface {
+ ValidateGitConfig(ctx context.Context) error
+}
+
type SuggestCommandFactory struct {
commitService commitService
commitHandler commitHandler
+ gitService gitService
}
-func NewSuggestCommandFactory(commitSvc commitService, commitHdlr commitHandler) *SuggestCommandFactory {
+func NewSuggestCommandFactory(commitSvc commitService, commitHdlr commitHandler, gitSvc gitService) *SuggestCommandFactory {
return &SuggestCommandFactory{
commitService: commitSvc,
commitHandler: commitHdlr,
+ gitService: gitSvc,
}
}
@@ -122,8 +127,7 @@ func (f *SuggestCommandFactory) createAction(cfg *config.Config, t *i18n.Transla
ui.PrintSectionBanner(t.GetMessage("ui.generating_suggestions_banner", 0, nil))
- gitSvc := git.NewGitService()
- if err := gitSvc.ValidateGitConfig(ctx); err != nil {
+ if err := f.gitService.ValidateGitConfig(ctx); err != nil {
ui.HandleAppError(err, t)
return err
}
diff --git a/internal/commands/suggests_commits/suggests_commits_test.go b/internal/commands/suggests_commits/suggests_commits_test.go
index f49c28a..6ab8ff5 100644
--- a/internal/commands/suggests_commits/suggests_commits_test.go
+++ b/internal/commands/suggests_commits/suggests_commits_test.go
@@ -33,6 +33,16 @@ func (m *MockCommitHandler) HandleSuggestions(ctx context.Context, suggestions [
return args.Error(0)
}
+// Mock para GitService
+type MockGitService struct {
+ mock.Mock
+}
+
+func (m *MockGitService) ValidateGitConfig(ctx context.Context) error {
+ args := m.Called(ctx)
+ return args.Error(0)
+}
+
func setupTestEnv(t *testing.T) (*config.Config, *i18n.Translations, func()) {
tmpDir, err := os.MkdirTemp("", "matecommit-test-*")
if err != nil {
@@ -67,6 +77,7 @@ func TestSuggestCommand(t *testing.T) {
mockService := new(MockCommitService)
mockHandler := new(MockCommitHandler)
+ mockGit := new(MockGitService)
ctx := context.Background()
suggestions := []models.CommitSuggestion{
@@ -77,10 +88,11 @@ func TestSuggestCommand(t *testing.T) {
},
}
+ mockGit.On("ValidateGitConfig", mock.Anything).Return(nil)
mockService.On("GenerateSuggestions", mock.Anything, cfg.SuggestionsCount, 0, mock.Anything).Return(suggestions, nil)
mockHandler.On("HandleSuggestions", mock.Anything, suggestions).Return(nil)
- factory := NewSuggestCommandFactory(mockService, mockHandler)
+ factory := NewSuggestCommandFactory(mockService, mockHandler, mockGit)
cmd := factory.CreateCommand(translations, cfg)
// Act
@@ -99,8 +111,9 @@ func TestSuggestCommand(t *testing.T) {
mockService := new(MockCommitService)
mockHandler := new(MockCommitHandler)
+ mockGit := new(MockGitService)
- factory := NewSuggestCommandFactory(mockService, mockHandler)
+ factory := NewSuggestCommandFactory(mockService, mockHandler, mockGit)
cmd := factory.CreateCommand(translations, cfg)
ctx := context.Background()
@@ -125,6 +138,7 @@ func TestSuggestCommand(t *testing.T) {
mockService := new(MockCommitService)
mockHandler := new(MockCommitHandler)
+ mockGit := new(MockGitService)
suggestions := []models.CommitSuggestion{
{
@@ -134,10 +148,11 @@ func TestSuggestCommand(t *testing.T) {
},
}
+ mockGit.On("ValidateGitConfig", mock.Anything).Return(nil)
mockService.On("GenerateSuggestions", mock.Anything, cfg.SuggestionsCount, 0, mock.Anything).Return(suggestions, nil)
mockHandler.On("HandleSuggestions", mock.Anything, suggestions).Return(nil)
- factory := NewSuggestCommandFactory(mockService, mockHandler)
+ factory := NewSuggestCommandFactory(mockService, mockHandler, mockGit)
command := factory.CreateCommand(translations, cfg)
ctx := context.Background()
@@ -159,6 +174,7 @@ func TestSuggestCommand(t *testing.T) {
mockService := new(MockCommitService)
mockHandler := new(MockCommitHandler)
+ mockGit := new(MockGitService)
suggestions := []models.CommitSuggestion{
{
@@ -168,10 +184,11 @@ func TestSuggestCommand(t *testing.T) {
},
}
+ mockGit.On("ValidateGitConfig", mock.Anything).Return(nil)
mockService.On("GenerateSuggestions", mock.Anything, cfg.SuggestionsCount, 0, mock.Anything).Return(suggestions, nil)
mockHandler.On("HandleSuggestions", mock.Anything, suggestions).Return(nil)
- factory := NewSuggestCommandFactory(mockService, mockHandler)
+ factory := NewSuggestCommandFactory(mockService, mockHandler, mockGit)
command := factory.CreateCommand(translations, cfg)
ctx := context.Background()
@@ -193,11 +210,13 @@ func TestSuggestCommand(t *testing.T) {
mockService := new(MockCommitService)
mockHandler := new(MockCommitHandler)
+ mockGit := new(MockGitService)
expectedError := fmt.Errorf("service error")
+ mockGit.On("ValidateGitConfig", mock.Anything).Return(nil)
mockService.On("GenerateSuggestions", mock.Anything, cfg.SuggestionsCount, 0, mock.Anything).Return([]models.CommitSuggestion{}, expectedError)
- factory := NewSuggestCommandFactory(mockService, mockHandler)
+ factory := NewSuggestCommandFactory(mockService, mockHandler, mockGit)
command := factory.CreateCommand(translations, cfg)
ctx := context.Background()
diff --git a/internal/config/config.go b/internal/config/config.go
index 7bce3b5..acfc56a 100644
--- a/internal/config/config.go
+++ b/internal/config/config.go
@@ -27,6 +27,7 @@ type (
UpdateChangelog bool `json:"update_changelog"`
VersionFile string `json:"version_file,omitempty"`
VersionPattern string `json:"version_pattern,omitempty"`
+ AutoFetchTags bool `json:"auto_fetch_tags"`
}
AIProviderConfig struct {
@@ -51,7 +52,7 @@ type (
}
VCSConfig struct {
- Provider string `json:"provider"` // github or gitlab or whatever you want
+ Provider string `json:"provider"` // GitHub or gitlab or whatever you want
Token string `json:"token,omitempty"`
Owner string `json:"owner,omitempty"`
Repo string `json:"repo,omitempty"`
diff --git a/internal/errors/errors.go b/internal/errors/errors.go
index 3582795..743e8f0 100644
--- a/internal/errors/errors.go
+++ b/internal/errors/errors.go
@@ -23,16 +23,52 @@ type AppError struct {
}
func (e *AppError) Error() string {
+ var msg string
if e.Err != nil {
- return fmt.Sprintf("%s: %s (%v)", e.Type, e.Message, e.Err)
+ msg = fmt.Sprintf("%s: %s (%v)", e.Type, e.Message, e.Err)
+ } else {
+ msg = fmt.Sprintf("%s: %s", e.Type, e.Message)
}
- return fmt.Sprintf("%s: %s", e.Type, e.Message)
+
+ // Include stderr context if available for better error messages
+ if e.Context != nil {
+ if stderr, ok := e.Context["stderr"].(string); ok && stderr != "" {
+ msg += fmt.Sprintf(" - %s", stderr)
+ }
+ }
+
+ return msg
}
func (e *AppError) Unwrap() error {
return e.Err
}
+// WithError creates a new AppError with an underlying error
+func (e *AppError) WithError(err error) *AppError {
+ return &AppError{
+ Type: e.Type,
+ Message: e.Message,
+ Context: e.Context,
+ Err: err,
+ }
+}
+
+// WithContext creates a new AppError with additional context
+func (e *AppError) WithContext(key string, value interface{}) *AppError {
+ ctx := make(map[string]interface{})
+ for k, v := range e.Context {
+ ctx[k] = v
+ }
+ ctx[key] = value
+ return &AppError{
+ Type: e.Type,
+ Message: e.Message,
+ Context: ctx,
+ Err: e.Err,
+ }
+}
+
// NewAppError creates a new AppError
func NewAppError(t ErrorType, msg string, err error) *AppError {
return &AppError{
@@ -44,23 +80,30 @@ func NewAppError(t ErrorType, msg string, err error) *AppError {
// Git errors
var (
- ErrNoChanges = NewAppError(TypeGit, "no staged changes detected", nil)
- ErrGetBranch = NewAppError(TypeGit, "failed to get current branch", nil)
- ErrNoBranch = NewAppError(TypeGit, "no branch detected", nil)
- ErrGetRepoRoot = NewAppError(TypeGit, "failed to get repository root", nil)
- ErrGetRepoURL = NewAppError(TypeGit, "failed to get repository URL", nil)
- ErrGetCommits = NewAppError(TypeGit, "failed to get commits", nil)
- ErrAddFile = NewAppError(TypeGit, "failed to add file to staging", nil)
- ErrExtractRepoInfo = NewAppError(TypeGit, "failed to extract repository info", nil)
- ErrCreateTag = NewAppError(TypeGit, "failed to create tag", nil)
- ErrPushTag = NewAppError(TypeGit, "failed to push tag", nil)
- ErrCreateCommit = NewAppError(TypeGit, "failed to create commit", nil)
- ErrGetDiff = NewAppError(TypeGit, "failed to get diff", nil)
- ErrNoDiff = NewAppError(TypeGit, "no differences detected", nil)
-)
-
-// Git configuration errors
-var (
+ ErrNoChanges = NewAppError(TypeGit, "no staged changes detected", nil)
+ ErrGetBranch = NewAppError(TypeGit, "failed to get current branch", nil)
+ ErrNoBranch = NewAppError(TypeGit, "no branch detected", nil)
+ ErrGetRepoRoot = NewAppError(TypeGit, "failed to get repository root", nil)
+ ErrGetRepoURL = NewAppError(TypeGit, "failed to get repository URL", nil)
+ ErrGetCommits = NewAppError(TypeGit, "failed to get commits", nil)
+ ErrGetCommitCount = NewAppError(TypeGit, "failed to get commit count", nil)
+ ErrGetRecentCommits = NewAppError(TypeGit, "failed to get recent commit messages", nil)
+ ErrAddFile = NewAppError(TypeGit, "failed to add file to staging", nil)
+ ErrExtractRepoInfo = NewAppError(TypeGit, "failed to extract repository info", nil)
+ ErrCreateTag = NewAppError(TypeGit, "failed to create tag", nil)
+ ErrPushTag = NewAppError(TypeGit, "failed to push tag", nil)
+ ErrPush = NewAppError(TypeGit, "failed to push to remote", nil)
+ ErrFetchTags = NewAppError(TypeGit, "failed to fetch tags from remote", nil)
+ ErrCreateCommit = NewAppError(TypeGit, "failed to create commit", nil)
+ ErrGetDiff = NewAppError(TypeGit, "failed to get diff", nil)
+ ErrNoDiff = NewAppError(TypeGit, "no differences detected", nil)
+ ErrInvalidBranch = NewAppError(TypeGit, "must be on main or master branch to create releases", nil)
+ ErrTagNotFound = NewAppError(TypeGit, "tag not found in repository history", nil)
+ ErrInvalidTagFormat = NewAppError(TypeGit, "tag does not match semver format (vX.Y.Z)", nil)
+ ErrValidateTag = NewAppError(TypeGit, "failed to validate tag existence", nil)
+ ErrGetChangedFiles = NewAppError(TypeGit, "failed to get changed files", nil)
+ ErrGetTagDate = NewAppError(TypeGit, "failed to get tag date", nil)
+ ErrGetGitUser = NewAppError(TypeGit, "failed to get git user configuration", nil)
ErrGitUserNotConfigured = NewAppError(TypeGit, "git user.name not configured", nil)
ErrGitEmailNotConfigured = NewAppError(TypeGit, "git user.email not configured", nil)
ErrNotInGitRepo = NewAppError(TypeGit, "not in a git repository", nil)
@@ -103,20 +146,15 @@ var (
ErrGeminiQuotaExceeded = NewAppError(TypeAI, "Gemini API quota exceeded", nil)
)
-// Internal errors
-var (
- ErrNetwork = NewAppError(TypeInternal, "network error occurred", nil)
- ErrBuild = NewAppError(TypeInternal, "build operation failed", nil)
-)
-
// Update errors
var (
ErrUpdateFailed = NewAppError(TypeUpdate, "failed to update application", nil)
)
var (
- ErrBuildNoVersion = NewAppError(TypeInternal, "build version not specified", nil)
- ErrBuildNoCommit = NewAppError(TypeInternal, "build commit not specified", nil)
- ErrBuildNoBuildDir = NewAppError(TypeInternal, "build directory not specified", nil)
- ErrBuildNoDate = NewAppError(TypeInternal, "build date not specified", nil)
- ErrBuildFailed = NewAppError(TypeInternal, "build operation failed", nil)
+ ErrBuildNoVersion = NewAppError(TypeInternal, "build version not specified", nil)
+ ErrBuildNoCommit = NewAppError(TypeInternal, "build commit not specified", nil)
+ ErrBuildNoBuildDir = NewAppError(TypeInternal, "build directory not specified", nil)
+ ErrBuildNoDate = NewAppError(TypeInternal, "build date not specified", nil)
+ ErrBuildFailed = NewAppError(TypeInternal, "build operation failed", nil)
+ ErrVersionFileNotFound = NewAppError(TypeInternal, "version file not found", nil)
)
diff --git a/internal/errors/errors_test.go b/internal/errors/errors_test.go
new file mode 100644
index 0000000..b2190b5
--- /dev/null
+++ b/internal/errors/errors_test.go
@@ -0,0 +1,132 @@
+package errors
+
+import (
+ "errors"
+ "testing"
+)
+
+func TestAppError_WithError(t *testing.T) {
+ baseErr := errors.New("original error")
+ appErr := ErrGetDiff.WithError(baseErr)
+
+ if appErr.Err != baseErr {
+ t.Errorf("Expected underlying error to be %v, got %v", baseErr, appErr.Err)
+ }
+
+ if appErr.Type != TypeGit {
+ t.Errorf("Expected type %s, got %s", TypeGit, appErr.Type)
+ }
+}
+
+func TestAppError_WithContext(t *testing.T) {
+ appErr := ErrAddFile.WithContext("file", "test.txt").WithContext("stderr", "file not found")
+
+ if appErr.Context["file"] != "test.txt" {
+ t.Errorf("Expected file context 'test.txt', got %v", appErr.Context["file"])
+ }
+
+ if appErr.Context["stderr"] != "file not found" {
+ t.Errorf("Expected stderr context 'file not found', got %v", appErr.Context["stderr"])
+ }
+}
+
+func TestAppError_Error_Format(t *testing.T) {
+ tests := []struct {
+ name string
+ err *AppError
+ contains []string
+ }{
+ {
+ name: "Simple error without underlying error",
+ err: ErrNoChanges,
+ contains: []string{
+ "GIT",
+ "no staged changes detected",
+ },
+ },
+ {
+ name: "Error with underlying error",
+ err: ErrGetBranch.WithError(errors.New("exit status 1")),
+ contains: []string{
+ "GIT",
+ "failed to get current branch",
+ "exit status 1",
+ },
+ },
+ {
+ name: "Error with context including stderr",
+ err: ErrAddFile.WithError(errors.New("exit status 128")).
+ WithContext("file", "test.go").
+ WithContext("stderr", "did not match any files"),
+ contains: []string{
+ "GIT",
+ "failed to add file to staging",
+ "exit status 128",
+ "did not match any files",
+ },
+ },
+ {
+ name: "Error with multiple context fields",
+ err: ErrGetDiff.WithError(errors.New("command failed")).
+ WithContext("diff_type", "staged").
+ WithContext("stderr", "repository not found"),
+ contains: []string{
+ "GIT",
+ "failed to get diff",
+ "command failed",
+ "repository not found",
+ },
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ errMsg := tt.err.Error()
+ for _, substr := range tt.contains {
+ if !contains(errMsg, substr) {
+ t.Errorf("Expected error message to contain %q, got: %s", substr, errMsg)
+ }
+ }
+ })
+ }
+}
+
+func TestAppError_Unwrap(t *testing.T) {
+ baseErr := errors.New("base error")
+ appErr := ErrCreateCommit.WithError(baseErr)
+
+ unwrapped := appErr.Unwrap()
+ if unwrapped != baseErr {
+ t.Errorf("Expected unwrapped error to be %v, got %v", baseErr, unwrapped)
+ }
+
+ // Test errors.Is functionality
+ if !errors.Is(appErr, baseErr) {
+ t.Error("errors.Is should work with AppError")
+ }
+}
+
+func TestAppError_ChainedContext(t *testing.T) {
+ appErr := ErrCreateTag.
+ WithError(errors.New("tag exists")).
+ WithContext("version", "v1.0.0").
+ WithContext("remote", "origin")
+
+ if appErr.Context["version"] != "v1.0.0" {
+ t.Errorf("Expected version context, got %v", appErr.Context["version"])
+ }
+
+ if appErr.Context["remote"] != "origin" {
+ t.Errorf("Expected remote context, got %v", appErr.Context["remote"])
+ }
+
+ // Ensure we didn't modify the original error
+ if ErrCreateTag.Context != nil {
+ t.Error("Original error should not have context")
+ }
+}
+
+func contains(s, substr string) bool {
+ return len(s) >= len(substr) && (s == substr || len(substr) == 0 ||
+ (len(s) > 0 && (s[:len(substr)] == substr || contains(s[1:], substr))))
+}
diff --git a/internal/git/git_service.go b/internal/git/git_service.go
index d7415e9..d82a6dc 100644
--- a/internal/git/git_service.go
+++ b/internal/git/git_service.go
@@ -4,12 +4,15 @@ import (
"context"
"fmt"
"os/exec"
+ "regexp"
+ "sort"
"strings"
"github.com/thomas-vilte/matecommit/internal/errors"
"github.com/thomas-vilte/matecommit/internal/logger"
"github.com/thomas-vilte/matecommit/internal/models"
"github.com/thomas-vilte/matecommit/internal/regex"
+ "golang.org/x/mod/semver"
)
type GitService struct{}
@@ -37,7 +40,7 @@ func (s *GitService) GetChangedFiles(ctx context.Context) ([]string, error) {
if err != nil {
log.Error("git status failed",
"error", err)
- return nil, err
+ return nil, errors.ErrGetChangedFiles.WithError(err)
}
changes := make([]string, 0)
@@ -69,7 +72,7 @@ func (s *GitService) GetDiff(ctx context.Context) (string, error) {
if err != nil {
log.Error("git diff --cached failed",
"error", err)
- return "", err
+ return "", errors.ErrGetDiff.WithError(err).WithContext("diff_type", "staged")
}
unstagedCmd := exec.CommandContext(ctx, "git", "diff")
@@ -77,7 +80,7 @@ func (s *GitService) GetDiff(ctx context.Context) (string, error) {
if err != nil {
log.Error("git diff failed",
"error", err)
- return "", err
+ return "", errors.ErrGetDiff.WithError(err).WithContext("diff_type", "unstaged")
}
combinedDiff := string(stagedOutput) + string(unstageOutput)
@@ -97,6 +100,12 @@ func (s *GitService) GetDiff(ctx context.Context) (string, error) {
}
}
}
+
+ // If still no diff after checking untracked files
+ if combinedDiff == "" {
+ log.Warn("no differences detected in repository")
+ return "", errors.ErrNoDiff
+ }
}
log.Debug("git diff completed",
@@ -141,8 +150,7 @@ func (s *GitService) CreateCommit(ctx context.Context, message string) error {
return errors.ErrGitEmailNotConfigured
}
- fullErr := fmt.Sprintf("%v: %s", err, stderrStr)
- return fmt.Errorf("%w: %s", errors.ErrCreateCommit, fullErr)
+ return errors.ErrCreateCommit.WithError(err).WithContext("stderr", stderrStr)
}
log.Info("git commit created successfully")
@@ -153,7 +161,7 @@ func (s *GitService) CreateCommit(ctx context.Context, message string) error {
func (s *GitService) AddFileToStaging(ctx context.Context, file string) error {
repoRoot, err := s.getRepoRoot(ctx)
if err != nil {
- return fmt.Errorf("%w: %v", errors.ErrGetRepoRoot, err)
+ return err
}
cmd := exec.CommandContext(ctx, "git", "add", "-A", "--", file)
@@ -162,8 +170,8 @@ func (s *GitService) AddFileToStaging(ctx context.Context, file string) error {
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
- fullErr := fmt.Sprintf("%v: %s", err, strings.TrimSpace(stderr.String()))
- return fmt.Errorf("%w [%s]: %s", errors.ErrAddFile, file, fullErr)
+ stderrStr := strings.TrimSpace(stderr.String())
+ return errors.ErrAddFile.WithError(err).WithContext("file", file).WithContext("stderr", stderrStr)
}
return nil
}
@@ -178,7 +186,7 @@ func (s *GitService) GetCurrentBranch(ctx context.Context) (string, error) {
if err != nil {
log.Error("failed to get current branch",
"error", err)
- return "", fmt.Errorf("%w: %v", errors.ErrGetBranch, err)
+ return "", errors.ErrGetBranch.WithError(err)
}
branchName := strings.TrimSpace(string(output))
@@ -203,7 +211,7 @@ func (s *GitService) GetRepoInfo(ctx context.Context) (string, string, string, e
if err != nil {
log.Error("failed to get remote URL",
"error", err)
- return "", "", "", fmt.Errorf("%w: %v", errors.ErrGetRepoURL, err)
+ return "", "", "", errors.ErrGetRepoURL.WithError(err)
}
url := strings.TrimSpace(string(output))
@@ -224,28 +232,59 @@ func (s *GitService) GetRepoInfo(ctx context.Context) (string, string, string, e
}
func (s *GitService) GetLastTag(ctx context.Context) (string, error) {
- cmd := exec.CommandContext(ctx, "git", "describe", "--tags", "--abbrev=0")
+ log := logger.FromContext(ctx)
+
+ cmd := exec.CommandContext(ctx, "git", "ls-remote", "--tags", "origin", "refs/tags/*")
output, err := cmd.Output()
+ if err == nil && len(output) > 0 {
+ tags := s.parseRemoteTags(string(output))
+ if len(tags) > 0 {
+ lastTag := s.getLatestSemverTag(tags)
+ if lastTag != "" {
+ log.Debug("last tag from remote", "tag", lastTag)
+ return lastTag, nil
+ }
+ }
+ }
+
+ cmd = exec.CommandContext(ctx, "git", "describe", "--tags", "--abbrev=0", "--match", "v*")
+ output, err = cmd.Output()
if err != nil {
- // no tags found
return "", nil
}
- return strings.TrimSpace(string(output)), nil
+
+ tag := strings.TrimSpace(string(output))
+
+ if tag != "" {
+ if err := s.ValidateTagExists(ctx, tag); err != nil {
+ log.Warn("tag not found in current branch", "tag", tag, "error", err)
+ return s.getLastTagInCurrentBranch(ctx)
+ }
+ }
+ return tag, nil
}
func (s *GitService) GetCommitsSinceTag(ctx context.Context, tag string) ([]models.Commit, error) {
+ log := logger.FromContext(ctx)
+
var args []string
if tag == "" {
// if no previous tag exists, get all commits
- args = []string{"log", "--pretty=format:%H|%s|%b", "--no-merges"}
+ args = []string{"log", "--pretty=format:%H|%an|%ae|%ad|%s|%b", "--no-merges", "--date=iso"}
} else {
- args = []string{"log", tag + "..HEAD", "--pretty=format:%H|%s|%b", "--no-merges"}
+ if err := s.ValidateTagExists(ctx, tag); err != nil {
+ log.Warn("tag not found, trying to fetch from remote", "tag", tag)
+ _ = exec.CommandContext(ctx, "git", "fetch", "origin", "tag", tag).Run()
+ }
+
+ args = []string{"log", tag + "..HEAD", "--pretty=format:%H|%an|%ae|%ad|%s|%b", "--no-merges", "--date=iso"}
}
cmd := exec.CommandContext(ctx, "git", args...)
output, err := cmd.Output()
if err != nil {
- return nil, fmt.Errorf("%w: %v", errors.ErrGetCommits, err)
+ log.Warn("failed to get commits with tag range, trying alternative", "error", err)
+ return s.getCommitsAlternative(ctx, tag)
}
if len(output) == 0 {
@@ -259,13 +298,17 @@ func (s *GitService) GetCommitsSinceTag(ctx context.Context, tag string) ([]mode
if line == "" {
continue
}
- parts := strings.SplitN(line, "|", 3)
- if len(parts) >= 2 {
+ parts := strings.SplitN(line, "|", 6)
+ if len(parts) >= 5 {
commit := models.Commit{
- Message: parts[1],
+ Hash: parts[0],
+ Author: parts[1],
+ Email: parts[2],
+ Date: parts[3],
+ Message: parts[4],
}
- if len(parts) == 3 {
- commit.Message = parts[1] + "\n" + parts[2]
+ if len(parts) == 6 && parts[5] != "" {
+ commit.Message = parts[4] + "\n" + parts[5]
}
commits = append(commits, commit)
}
@@ -283,7 +326,7 @@ func (s *GitService) GetCommitsBetweenTags(ctx context.Context, fromTag, toTag s
cmd := exec.CommandContext(ctx, "git", args...)
output, err := cmd.Output()
if err != nil {
- return nil, fmt.Errorf("%w: %v", errors.ErrGetCommits, err)
+ return nil, errors.ErrGetCommits.WithError(err).WithContext("from_tag", fromTag).WithContext("to_tag", toTag)
}
if len(output) == 0 {
@@ -315,7 +358,7 @@ func (s *GitService) GetRecentCommitMessages(ctx context.Context, count int) ([]
cmd := exec.CommandContext(ctx, "git", "log", fmt.Sprintf("-%d", count), "--pretty=format:%s %b")
output, err := cmd.Output()
if err != nil {
- return nil, err
+ return nil, errors.ErrGetRecentCommits.WithError(err).WithContext("count", count)
}
lines := strings.Split(strings.TrimSpace(string(output)), "\n")
return lines, nil
@@ -323,25 +366,34 @@ func (s *GitService) GetRecentCommitMessages(ctx context.Context, count int) ([]
func (s *GitService) CreateTag(ctx context.Context, version, message string) error {
cmd := exec.CommandContext(ctx, "git", "tag", "-a", version, "-m", message)
- return cmd.Run()
+ if err := cmd.Run(); err != nil {
+ return errors.ErrCreateTag.WithError(err).WithContext("version", version)
+ }
+ return nil
}
func (s *GitService) PushTag(ctx context.Context, version string) error {
cmd := exec.CommandContext(ctx, "git", "push", "origin", version)
- return cmd.Run()
+ if err := cmd.Run(); err != nil {
+ return errors.ErrPushTag.WithError(err).WithContext("version", version)
+ }
+ return nil
}
// Push pushes commits to the remote repository
func (s *GitService) Push(ctx context.Context) error {
cmd := exec.CommandContext(ctx, "git", "push")
- return cmd.Run()
+ if err := cmd.Run(); err != nil {
+ return errors.ErrPush.WithError(err)
+ }
+ return nil
}
func (s *GitService) GetCommitCount(ctx context.Context) (int, error) {
cmd := exec.CommandContext(ctx, "git", "rev-list", "--count", "HEAD")
output, err := cmd.Output()
if err != nil {
- return 0, err
+ return 0, errors.ErrGetCommitCount.WithError(err)
}
count := 0
_, _ = fmt.Sscanf(strings.TrimSpace(string(output)), "%d", &count)
@@ -353,17 +405,31 @@ func (s *GitService) GetTagDate(ctx context.Context, tag string) (string, error)
cmd := exec.CommandContext(ctx, "git", "log", "-1", "--format=%ai", tag)
output, err := cmd.Output()
if err != nil {
- return "", fmt.Errorf("error getting tag date: %w", err)
+ return "", errors.ErrGetTagDate.WithError(err).WithContext("tag", tag)
}
dateStr := strings.TrimSpace(string(output))
if len(dateStr) >= 10 {
- return dateStr[:10], nil // Return YYYY-MM-DD
+ return dateStr[:10], nil
}
return dateStr, nil
}
+func (s *GitService) FetchTags(ctx context.Context) error {
+ log := logger.FromContext(ctx)
+ log.Debug("fetching tags from remote")
+
+ cmd := exec.CommandContext(ctx, "git", "fetch", "--tags", "origin")
+ if err := cmd.Run(); err != nil {
+ log.Warn("failed to fetch tags from remote", "error", err)
+ return errors.ErrFetchTags.WithError(err)
+ }
+
+ log.Debug("tags fetched successfully")
+ return nil
+}
+
// ValidateGitConfig checks if git user.name and user.email are configured
func (s *GitService) ValidateGitConfig(ctx context.Context) error {
cmd := exec.CommandContext(ctx, "git", "rev-parse", "--git-dir")
@@ -390,7 +456,7 @@ func (s *GitService) GetGitUserName(ctx context.Context) (string, error) {
cmd := exec.CommandContext(ctx, "git", "config", "user.name")
output, err := cmd.Output()
if err != nil {
- return "", err
+ return "", errors.ErrGetGitUser.WithError(err).WithContext("config_key", "user.name")
}
return strings.TrimSpace(string(output)), nil
}
@@ -400,11 +466,19 @@ func (s *GitService) GetGitUserEmail(ctx context.Context) (string, error) {
cmd := exec.CommandContext(ctx, "git", "config", "user.email")
output, err := cmd.Output()
if err != nil {
- return "", err
+ return "", errors.ErrGetGitUser.WithError(err).WithContext("config_key", "user.email")
}
return strings.TrimSpace(string(output)), nil
}
+func (s *GitService) ValidateTagExists(ctx context.Context, tag string) error {
+ cmd := exec.CommandContext(ctx, "git", "rev-parse", "--verify", tag+"^{commit}")
+ if err := cmd.Run(); err != nil {
+ return errors.ErrValidateTag.WithError(err).WithContext("tag", tag)
+ }
+ return nil
+}
+
func parseRepoURL(url string) (string, string, string, error) {
var matches []string
if regex.SSHRepo.MatchString(url) {
@@ -419,7 +493,7 @@ func parseRepoURL(url string) (string, string, string, error) {
return matches[2], repoName, provider, nil
}
- return "", "", "", fmt.Errorf("%w [%s]", errors.ErrExtractRepoInfo, url)
+ return "", "", "", errors.ErrExtractRepoInfo.WithContext("url", url)
}
func detectProvider(host string) string {
@@ -437,7 +511,90 @@ func (s *GitService) getRepoRoot(ctx context.Context) (string, error) {
cmd := exec.CommandContext(ctx, "git", "rev-parse", "--show-toplevel")
output, err := cmd.Output()
if err != nil {
- return "", fmt.Errorf("%w: %v", errors.ErrGetRepoRoot, err)
+ return "", errors.ErrGetRepoRoot.WithError(err)
}
return strings.TrimSpace(string(output)), nil
}
+
+func (s *GitService) parseRemoteTags(output string) []string {
+ var tags []string
+ lines := strings.Split(output, "\n")
+ for _, line := range lines {
+ parts := strings.Fields(line)
+ if len(parts) >= 2 {
+ ref := parts[1]
+ if strings.HasPrefix(ref, "refs/tags/") {
+ tag := strings.TrimPrefix(ref, "refs/tags/")
+ if regex.SemVer.MatchString(tag) {
+ tags = append(tags, tag)
+ }
+ }
+ }
+ }
+ return tags
+}
+
+func (s *GitService) getLatestSemverTag(tags []string) string {
+ if len(tags) == 0 {
+ return ""
+ }
+
+ sort.Slice(tags, func(i, j int) bool {
+ return semver.Compare("v"+strings.TrimPrefix(tags[i], "v"), "v"+strings.TrimPrefix(tags[j], "v")) > 0
+ })
+ return tags[0]
+}
+
+func (s *GitService) getLastTagInCurrentBranch(ctx context.Context) (string, error) {
+ cmd := exec.CommandContext(ctx, "git", "log", "--oneline", "--decorate", "--simplify-by-decoration", "--all", "--match", "v*", "-1")
+ output, err := cmd.Output()
+ if err != nil {
+ return "", errors.ErrGetCommits.WithError(err).WithContext("operation", "get_last_tag_in_branch")
+ }
+
+ line := strings.TrimSpace(string(output))
+ tagMatch := regexp.MustCompile(`\(tag:\s*(v[\d.]+)\)`).FindStringSubmatch(line)
+ if len(tagMatch) > 1 {
+ return tagMatch[1], nil
+ }
+ return "", nil
+}
+
+func (s *GitService) getCommitsAlternative(ctx context.Context, tag string) ([]models.Commit, error) {
+ cmd := exec.CommandContext(ctx, "git", "merge-base", tag, "HEAD")
+ mergeBase, err := cmd.Output()
+ if err != nil {
+ return nil, errors.ErrTagNotFound.WithError(err).WithContext("tag", tag)
+ }
+
+ baseHash := strings.TrimSpace(string(mergeBase))
+ args := []string{"log", baseHash + "..HEAD", "--pretty=format:%H|%an|%ae|%ad|%s|%b", "--no-merges", "--date=iso"}
+
+ cmd = exec.CommandContext(ctx, "git", args...)
+ output, err := cmd.Output()
+ if err != nil {
+ return nil, errors.ErrGetCommits.WithError(err).WithContext("tag", tag).WithContext("operation", "alternative")
+ }
+ var commits []models.Commit
+ lines := strings.Split(string(output), "\n")
+ for _, line := range lines {
+ if line == "" {
+ continue
+ }
+ parts := strings.SplitN(line, "|", 6)
+ if len(parts) >= 5 {
+ commit := models.Commit{
+ Hash: parts[0],
+ Author: parts[1],
+ Email: parts[2],
+ Date: parts[3],
+ Message: parts[4],
+ }
+ if len(parts) == 6 && parts[5] != "" {
+ commit.Message = parts[4] + "\n" + parts[5]
+ }
+ commits = append(commits, commit)
+ }
+ }
+ return commits, nil
+}
diff --git a/internal/git/git_service_test.go b/internal/git/git_service_test.go
index ec624c5..9457585 100644
--- a/internal/git/git_service_test.go
+++ b/internal/git/git_service_test.go
@@ -3,14 +3,15 @@ package git
import (
"context"
"errors"
+ "fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
- domainErrors "github.com/thomas-vilte/matecommit/internal/errors"
"github.com/stretchr/testify/assert"
+ domainErrors "github.com/thomas-vilte/matecommit/internal/errors"
)
var originalDir string
@@ -70,22 +71,18 @@ func TestGitService(t *testing.T) {
service := NewGitService()
- // Act - Verificar sin cambios staged
hasStagedBefore := service.HasStagedChanges(context.Background())
- // Crear y hacer stage de un archivo
testFile := filepath.Join("test.txt")
if err := os.WriteFile(testFile, []byte("test content"), 0644); err != nil {
t.Fatalf("Error creando archivo de prueba: %v", err)
}
- // Stage el archivo
cmd := exec.Command("git", "add", "test.txt")
if err := cmd.Run(); err != nil {
t.Fatalf("Error haciendo stage del archivo: %v", err)
}
- // Act - Verificar con cambios staged
hasStagedAfter := service.HasStagedChanges(context.Background())
// Assert
@@ -104,7 +101,6 @@ func TestGitService(t *testing.T) {
service := NewGitService()
- // Act - Obtener la branch actual (debería ser 'main' por defecto)
branchName, err := service.GetCurrentBranch(context.Background())
// Assert
@@ -321,9 +317,12 @@ func TestGitService(t *testing.T) {
// Act
diff, err := service.GetDiff(context.Background())
- // Assert
- if err != nil {
- t.Errorf("Error obteniendo diff: %v", err)
+ if err == nil {
+ t.Error("Expected ErrNoDiff when there are no changes")
+ }
+
+ if !errors.Is(err, domainErrors.ErrNoDiff) {
+ t.Errorf("Expected ErrNoDiff, got: %v", err)
}
if diff != "" {
@@ -348,7 +347,6 @@ func TestAddFileToStaging(t *testing.T) {
t.Fatalf("Error inesperado: %v", err)
}
- // verificar staging
cmd := exec.Command("git", "diff", "--cached", "--name-status")
output, _ := cmd.Output()
if !strings.Contains(string(output), "A\t"+testFile) {
@@ -363,7 +361,6 @@ func TestAddFileToStaging(t *testing.T) {
service := NewGitService()
testFile := "deleted.txt"
- // Crear y committear archivo
if err := os.WriteFile(testFile, []byte("test"), 0644); err != nil {
return
}
@@ -374,7 +371,6 @@ func TestAddFileToStaging(t *testing.T) {
t.Fatalf("Error al crear commit inicial: %v", err)
}
- // Eliminar y agregar al staging
if err := os.Remove(testFile); err != nil {
return
}
@@ -383,7 +379,6 @@ func TestAddFileToStaging(t *testing.T) {
t.Fatalf("Error inesperado: %v", err)
}
- // Verificar eliminación en staging
cmd := exec.Command("git", "diff", "--cached", "--name-status")
output, _ := cmd.Output()
if !strings.Contains(string(output), "D\t"+testFile) {
@@ -534,12 +529,10 @@ func TestGitService_NewMethods(t *testing.T) {
service := NewGitService()
- // Caso 1: Sin tags
tag, err := service.GetLastTag(context.Background())
assert.NoError(t, err)
assert.Equal(t, "", tag, "Debería retornar string vacío si no hay tags")
- // Caso 2: Con tags
createCommitHelper(t, "file1.txt", "Initial commit")
err = exec.Command("git", "tag", "-a", "v0.1.0", "-m", "Version 0.1.0").Run()
@@ -560,22 +553,18 @@ func TestGitService_NewMethods(t *testing.T) {
service := NewGitService()
- // Crear historial
createCommitHelper(t, "init.txt", "chore: initial commit")
_ = exec.Command("git", "tag", "-a", "v1.0.0", "-m", "v1.0.0").Run()
createCommitHelper(t, "feat.txt", "feat: amazing feature")
createCommitHelper(t, "fix.txt", "fix: critical bug")
- // Caso 1: Commits desde v1.0.0
commits, err := service.GetCommitsSinceTag(context.Background(), "v1.0.0")
assert.NoError(t, err)
assert.Len(t, commits, 2)
- // Git log suele devolver en orden cronológico inverso (el más reciente primero)
assert.Contains(t, commits[0].Message, "fix: critical bug")
assert.Contains(t, commits[1].Message, "feat: amazing feature")
- // Caso 2: Sin tag previo (debería traer todos)
commits, err = service.GetCommitsSinceTag(context.Background(), "")
assert.NoError(t, err)
assert.Len(t, commits, 3) // feat, fix, chore
@@ -591,7 +580,6 @@ func TestGitService_NewMethods(t *testing.T) {
err := service.CreateTag(context.Background(), "v2.0.0", "Release v2.0.0")
assert.NoError(t, err)
- // Verificar que el tag existe
output, _ := exec.Command("git", "tag", "-l", "v2.0.0").Output()
assert.Contains(t, string(output), "v2.0.0")
})
@@ -602,8 +590,6 @@ func TestGitService_NewMethods(t *testing.T) {
service := NewGitService()
- // Repositorio vacío (recién inicializado, sin commits) puede dar error o 0 dependiendo de la versión de git/setup
- // Vamos a crear al menos uno para asegurar
createCommitHelper(t, "one.txt", "one")
count, err := service.GetCommitCount(context.Background())
assert.NoError(t, err)
@@ -616,11 +602,9 @@ func TestGitService_NewMethods(t *testing.T) {
})
t.Run("PushTag", func(t *testing.T) {
- // Setup local repo
localDir := setupTestRepo(t)
defer cleanupTestRepo(t, localDir)
- // Setup bare remote repo to simulate origin
remoteDir, err := os.MkdirTemp("", "git-remote-test")
if err != nil {
t.Fatalf("Error creando dir remoto: %v", err)
@@ -631,12 +615,10 @@ func TestGitService_NewMethods(t *testing.T) {
}
}()
- // Init bare repo
if err := exec.Command("git", "init", "--bare", remoteDir).Run(); err != nil {
t.Fatalf("Error iniciando bare repo: %v", err)
}
- // Add remote to local
if err := exec.Command("git", "remote", "add", "origin", remoteDir).Run(); err != nil {
t.Fatalf("Error agregando remote: %v", err)
}
@@ -644,18 +626,368 @@ func TestGitService_NewMethods(t *testing.T) {
service := NewGitService()
createCommitHelper(t, "code.txt", "ready to release")
- // Push antes de tener el tag debería fallar o no hacer nada relevante, primero creamos el tag
err = service.CreateTag(context.Background(), "v1.0.0", "Release")
assert.NoError(t, err)
- // Test PushTag
err = service.PushTag(context.Background(), "v1.0.0")
assert.NoError(t, err)
- // Verificar en el remoto que el tag existe
cmd := exec.Command("git", "--git-dir", remoteDir, "tag")
out, err := cmd.Output()
assert.NoError(t, err)
assert.Contains(t, string(out), "v1.0.0")
})
}
+
+func TestGitService_ErrorHandling(t *testing.T) {
+ t.Run("GetDiff returns ErrNoDiff when no changes", func(t *testing.T) {
+ tempDir := setupTestRepo(t)
+ defer cleanupTestRepo(t, tempDir)
+
+ service := NewGitService()
+
+ testFile := "test.txt"
+ if err := os.WriteFile(testFile, []byte("initial content"), 0644); err != nil {
+ t.Fatalf("Error creating file: %v", err)
+ }
+ cmd := exec.Command("git", "add", "test.txt")
+ if err := cmd.Run(); err != nil {
+ t.Fatalf("Error staging file: %v", err)
+ }
+ cmd = exec.Command("git", "commit", "-m", "initial commit")
+ if err := cmd.Run(); err != nil {
+ t.Fatalf("Error committing: %v", err)
+ }
+
+ _, err := service.GetDiff(context.Background())
+ assert.Error(t, err)
+ assert.ErrorIs(t, err, domainErrors.ErrNoDiff)
+ })
+
+ t.Run("CreateTag with error returns proper AppError with context", func(t *testing.T) {
+ tempDir := setupTestRepo(t)
+ defer cleanupTestRepo(t, tempDir)
+
+ service := NewGitService()
+
+ testFile := "test.txt"
+ if err := os.WriteFile(testFile, []byte("test"), 0644); err != nil {
+ t.Fatalf("Error creating file: %v", err)
+ }
+ cmd := exec.Command("git", "add", "test.txt")
+ if err := cmd.Run(); err != nil {
+ t.Fatalf("Error staging: %v", err)
+ }
+ cmd = exec.Command("git", "commit", "-m", "initial")
+ if err := cmd.Run(); err != nil {
+ t.Fatalf("Error committing: %v", err)
+ }
+
+ err := service.CreateTag(context.Background(), "v1.0.0", "Release v1.0.0")
+ assert.NoError(t, err)
+
+ err = service.CreateTag(context.Background(), "v1.0.0", "Duplicate")
+ assert.Error(t, err)
+
+ var appErr *domainErrors.AppError
+ assert.True(t, errors.As(err, &appErr))
+ assert.Equal(t, domainErrors.TypeGit, appErr.Type)
+ assert.Equal(t, "v1.0.0", appErr.Context["version"])
+ })
+
+ t.Run("AddFileToStaging with non-existent file returns error with context", func(t *testing.T) {
+ tempDir := setupTestRepo(t)
+ defer cleanupTestRepo(t, tempDir)
+
+ service := NewGitService()
+
+ err := service.AddFileToStaging(context.Background(), "non-existent.txt")
+ assert.Error(t, err)
+
+ var appErr *domainErrors.AppError
+ assert.True(t, errors.As(err, &appErr))
+ assert.Equal(t, domainErrors.TypeGit, appErr.Type)
+ assert.Equal(t, "non-existent.txt", appErr.Context["file"])
+ errMsg := err.Error()
+ hasExpectedMsg := strings.Contains(errMsg, "did not match any files") ||
+ strings.Contains(errMsg, "no concordó con ningún archivo")
+ assert.True(t, hasExpectedMsg, "Expected error message about file not matching, got: %s", errMsg)
+ })
+
+ t.Run("GetCommitCount returns error with proper wrapping", func(t *testing.T) {
+ tempDir, err := os.MkdirTemp("", "not-git-repo")
+ assert.NoError(t, err)
+ defer func() {
+ if err := os.RemoveAll(tempDir); err != nil {
+ t.Fatalf("Error removing temp dir: %v", err)
+ }
+ }()
+
+ originalWd, _ := os.Getwd()
+ defer func() {
+ if err := os.Chdir(originalWd); err != nil {
+ t.Fatalf("Error changing working directory: %v", err)
+ }
+ }()
+
+ err = os.Chdir(tempDir)
+ assert.NoError(t, err)
+
+ service := NewGitService()
+ count, err := service.GetCommitCount(context.Background())
+ assert.Error(t, err)
+ assert.Equal(t, 0, count)
+
+ var appErr *domainErrors.AppError
+ assert.True(t, errors.As(err, &appErr))
+ assert.Equal(t, domainErrors.TypeGit, appErr.Type)
+ assert.Contains(t, err.Error(), "failed to get commit count")
+ })
+}
+
+// TestGitService_ValidateTagExists tests tag validation
+func TestGitService_ValidateTagExists(t *testing.T) {
+ t.Run("ValidateTagExists returns error for non-existent tag", func(t *testing.T) {
+ tempDir := setupTestRepo(t)
+ defer cleanupTestRepo(t, tempDir)
+
+ service := NewGitService()
+
+ testFile := "test.txt"
+ if err := os.WriteFile(testFile, []byte("test"), 0644); err != nil {
+ t.Fatalf("Error creating file: %v", err)
+ }
+ cmd := exec.Command("git", "add", "test.txt")
+ if err := cmd.Run(); err != nil {
+ t.Fatalf("Error staging: %v", err)
+ }
+ cmd = exec.Command("git", "commit", "-m", "test")
+ if err := cmd.Run(); err != nil {
+ t.Fatalf("Error committing: %v", err)
+ }
+
+ err := service.ValidateTagExists(context.Background(), "v999.999.999")
+ assert.Error(t, err)
+
+ var appErr *domainErrors.AppError
+ assert.True(t, errors.As(err, &appErr))
+ assert.Equal(t, "v999.999.999", appErr.Context["tag"])
+ })
+
+ t.Run("ValidateTagExists succeeds for existing tag", func(t *testing.T) {
+ tempDir := setupTestRepo(t)
+ defer cleanupTestRepo(t, tempDir)
+
+ service := NewGitService()
+
+ testFile := "test.txt"
+ if err := os.WriteFile(testFile, []byte("test"), 0644); err != nil {
+ t.Fatalf("Error creating file: %v", err)
+ }
+ cmd := exec.Command("git", "add", "test.txt")
+ if err := cmd.Run(); err != nil {
+ t.Fatalf("Error staging: %v", err)
+ }
+ cmd = exec.Command("git", "commit", "-m", "test")
+ if err := cmd.Run(); err != nil {
+ t.Fatalf("Error committing: %v", err)
+ }
+
+ err := service.CreateTag(context.Background(), "v1.0.0", "Test tag")
+ assert.NoError(t, err)
+
+ err = service.ValidateTagExists(context.Background(), "v1.0.0")
+ assert.NoError(t, err)
+ })
+}
+
+// TestGitService_GetRecentCommitMessages tests getting recent commit messages
+func TestGitService_GetRecentCommitMessages(t *testing.T) {
+ t.Run("GetRecentCommitMessages returns messages", func(t *testing.T) {
+ tempDir := setupTestRepo(t)
+ defer cleanupTestRepo(t, tempDir)
+
+ service := NewGitService()
+
+ commits := []string{"First commit", "Second commit", "Third commit"}
+ for i, msg := range commits {
+ testFile := filepath.Join(tempDir, fmt.Sprintf("file%d.txt", i))
+ if err := os.WriteFile(testFile, []byte("content"), 0644); err != nil {
+ t.Fatalf("Error creating file: %v", err)
+ }
+ cmd := exec.Command("git", "add", ".")
+ if err := cmd.Run(); err != nil {
+ t.Fatalf("Error staging: %v", err)
+ }
+ cmd = exec.Command("git", "commit", "-m", msg)
+ if err := cmd.Run(); err != nil {
+ t.Fatalf("Error committing: %v", err)
+ }
+ }
+
+ messages, err := service.GetRecentCommitMessages(context.Background(), 2)
+ assert.NoError(t, err)
+ assert.Len(t, messages, 2)
+ assert.Contains(t, messages[0], "Third commit")
+ assert.Contains(t, messages[1], "Second commit")
+ })
+
+ t.Run("GetRecentCommitMessages returns error with context when not in repo", func(t *testing.T) {
+ tempDir, err := os.MkdirTemp("", "not-git-repo")
+ assert.NoError(t, err)
+ defer func() {
+ if err := os.RemoveAll(tempDir); err != nil {
+ t.Fatalf("Error removing temp dir: %v", err)
+ }
+ }()
+
+ originalWd, _ := os.Getwd()
+ defer func() {
+ if err := os.Chdir(originalWd); err != nil {
+ t.Fatalf("Error changing working directory: %v", err)
+ }
+ }()
+
+ err = os.Chdir(tempDir)
+ assert.NoError(t, err)
+
+ service := NewGitService()
+ messages, err := service.GetRecentCommitMessages(context.Background(), 5)
+ assert.Error(t, err)
+ assert.Nil(t, messages)
+
+ var appErr *domainErrors.AppError
+ assert.True(t, errors.As(err, &appErr))
+ assert.Equal(t, domainErrors.TypeGit, appErr.Type)
+ assert.Equal(t, 5, appErr.Context["count"])
+ })
+}
+
+// TestGitService_GetGitUserInfo tests getting git user configuration
+func TestGitService_GetGitUserInfo(t *testing.T) {
+ t.Run("GetGitUserName returns configured name", func(t *testing.T) {
+ tempDir := setupTestRepo(t)
+ defer cleanupTestRepo(t, tempDir)
+
+ service := NewGitService()
+ name, err := service.GetGitUserName(context.Background())
+ assert.NoError(t, err)
+ assert.Equal(t, "Test User", name)
+ })
+
+ t.Run("GetGitUserEmail returns configured email", func(t *testing.T) {
+ tempDir := setupTestRepo(t)
+ defer cleanupTestRepo(t, tempDir)
+
+ service := NewGitService()
+ email, err := service.GetGitUserEmail(context.Background())
+ assert.NoError(t, err)
+ assert.Equal(t, "test@example.com", email)
+ })
+
+ t.Run("GetGitUserName returns error with context when not configured", func(t *testing.T) {
+ tempDir := setupTestRepo(t)
+ defer cleanupTestRepo(t, tempDir)
+
+ cmd := exec.Command("git", "config", "--local", "--unset", "user.name")
+ _ = cmd.Run()
+ cmd = exec.Command("git", "config", "--global", "--unset", "user.name")
+ _ = cmd.Run()
+
+ cmd = exec.Command("git", "config", "user.name")
+ _, err := cmd.Output()
+ if err == nil {
+ t.Skip("Cannot fully unset git user.name due to global/system config")
+ }
+
+ service := NewGitService()
+ name, err := service.GetGitUserName(context.Background())
+ if err != nil {
+ assert.Empty(t, name)
+ var appErr *domainErrors.AppError
+ if errors.As(err, &appErr) {
+ assert.Equal(t, "user.name", appErr.Context["config_key"])
+ }
+ }
+ })
+}
+
+func TestGitService_GetTagDate(t *testing.T) {
+ t.Run("GetTagDate returns date for existing tag", func(t *testing.T) {
+ tempDir := setupTestRepo(t)
+ defer cleanupTestRepo(t, tempDir)
+
+ service := NewGitService()
+
+ testFile := "test.txt"
+ if err := os.WriteFile(testFile, []byte("test"), 0644); err != nil {
+ t.Fatalf("Error creating file: %v", err)
+ }
+ cmd := exec.Command("git", "add", "test.txt")
+ if err := cmd.Run(); err != nil {
+ t.Fatalf("Error staging: %v", err)
+ }
+ cmd = exec.Command("git", "commit", "-m", "test")
+ if err := cmd.Run(); err != nil {
+ t.Fatalf("Error committing: %v", err)
+ }
+
+ err := service.CreateTag(context.Background(), "v1.0.0", "Release")
+ assert.NoError(t, err)
+
+ date, err := service.GetTagDate(context.Background(), "v1.0.0")
+ assert.NoError(t, err)
+ assert.NotEmpty(t, date)
+ assert.Len(t, date, 10)
+ })
+
+ t.Run("GetTagDate returns error with context for non-existent tag", func(t *testing.T) {
+ tempDir := setupTestRepo(t)
+ defer cleanupTestRepo(t, tempDir)
+
+ service := NewGitService()
+
+ date, err := service.GetTagDate(context.Background(), "v999.0.0")
+ assert.Error(t, err)
+ assert.Empty(t, date)
+
+ var appErr *domainErrors.AppError
+ assert.True(t, errors.As(err, &appErr))
+ assert.Equal(t, "v999.0.0", appErr.Context["tag"])
+ })
+}
+
+func TestGitService_Push(t *testing.T) {
+ t.Run("Push returns error when no remote configured", func(t *testing.T) {
+ tempDir := setupTestRepo(t)
+ defer cleanupTestRepo(t, tempDir)
+
+ service := NewGitService()
+
+ err := service.Push(context.Background())
+ assert.Error(t, err)
+
+ var appErr *domainErrors.AppError
+ assert.True(t, errors.As(err, &appErr))
+ assert.Equal(t, domainErrors.TypeGit, appErr.Type)
+ assert.Contains(t, err.Error(), "failed to push to remote")
+ })
+}
+
+func TestGitService_GetCommitsBetweenTags(t *testing.T) {
+ t.Run("GetCommitsBetweenTags returns error with tag context", func(t *testing.T) {
+ tempDir := setupTestRepo(t)
+ defer cleanupTestRepo(t, tempDir)
+
+ service := NewGitService()
+
+ commits, err := service.GetCommitsBetweenTags(context.Background(), "v1.0.0", "v2.0.0")
+ assert.Error(t, err)
+ assert.Nil(t, commits)
+
+ var appErr *domainErrors.AppError
+ assert.True(t, errors.As(err, &appErr))
+ assert.Equal(t, "v1.0.0", appErr.Context["from_tag"])
+ assert.Equal(t, "v2.0.0", appErr.Context["to_tag"])
+ })
+}
diff --git a/internal/i18n/locales/active.en.toml b/internal/i18n/locales/active.en.toml
index 049e81e..3cd3d6d 100644
--- a/internal/i18n/locales/active.en.toml
+++ b/internal/i18n/locales/active.en.toml
@@ -365,6 +365,7 @@ version_calculated = "📦 New version calculated: {{.Version}}"
error_updating_app_version = "Error updating app version: {{.Error}}"
app_version_updated = "✅ App version updated to {{.Version}}"
commit_no_staged = "No changes to commit. Skipping commit step."
+error_invalid_branch = "❌ Error: Must be on 'main' or 'master' branch to create releases. {{.Error}}"
# Generate Markdown
md_version = "Version"
diff --git a/internal/i18n/locales/active.es.toml b/internal/i18n/locales/active.es.toml
index 65f6ced..7e14fb4 100644
--- a/internal/i18n/locales/active.es.toml
+++ b/internal/i18n/locales/active.es.toml
@@ -335,6 +335,7 @@ files_changed = "Archivos modificados"
insertions = "Líneas agregadas"
deletions = "Líneas eliminadas"
warning_enrich_context = "No se pudo enriquecer el contexto del release: {Error}"
+error_invalid_branch = "❌ Error: Must be on 'main' or 'master' branch to create releases. {{.Error}}"
# Preview
preview_usage = "Ver vista previa del siguiente release sin crearlo"
diff --git a/internal/models/pr.go b/internal/models/pr.go
index 4a1f2c3..1b00d70 100644
--- a/internal/models/pr.go
+++ b/internal/models/pr.go
@@ -41,6 +41,10 @@ type (
// Commit represents a commit included in the PR.
Commit struct {
+ Hash string
+ Author string
+ Email string
+ Date string
Message string
}
diff --git a/internal/services/mocks.go b/internal/services/mocks.go
index 87e71e1..dd9387c 100644
--- a/internal/services/mocks.go
+++ b/internal/services/mocks.go
@@ -136,6 +136,16 @@ func (m *MockGitService) Push(ctx context.Context) error {
return args.Error(0)
}
+func (m *MockGitService) FetchTags(ctx context.Context) error {
+ args := m.Called(ctx)
+ return args.Error(0)
+}
+
+func (m *MockGitService) ValidateTagExists(ctx context.Context, tag string) error {
+ args := m.Called(ctx, tag)
+ return args.Error(0)
+}
+
func (m *MockAIProvider) GenerateSuggestions(ctx context.Context, info models.CommitInfo, count int) ([]models.CommitSuggestion, error) {
args := m.Called(ctx, info, count)
return args.Get(0).([]models.CommitSuggestion), args.Error(1)
diff --git a/internal/services/release_service.go b/internal/services/release_service.go
index 52d1597..91b5f99 100644
--- a/internal/services/release_service.go
+++ b/internal/services/release_service.go
@@ -4,6 +4,7 @@ import (
"context"
"fmt"
"os"
+ "path/filepath"
"regexp"
"strconv"
"strings"
@@ -15,8 +16,29 @@ import (
"github.com/thomas-vilte/matecommit/internal/models"
"github.com/thomas-vilte/matecommit/internal/ports"
"github.com/thomas-vilte/matecommit/internal/regex"
+ "golang.org/x/mod/semver"
)
+const (
+ maxSearchDepth = 5
+ maxFilesToScan = 100
+)
+
+var ignoreDirs = map[string]bool{
+ ".git": true,
+ "node_modules": true,
+ "vendor": true,
+ "target": true,
+ "dist": true,
+ "build": true,
+ ".next": true,
+ ".nuxt": true,
+ "__pycache__": true,
+ ".pytest_cache": true,
+ "venv": true,
+ ".venv": true,
+}
+
// releaseGitService defines only the methods needed by ReleaseService.
type releaseGitService interface {
GetLastTag(ctx context.Context) (string, error)
@@ -30,6 +52,9 @@ type releaseGitService interface {
CreateCommit(ctx context.Context, message string) error
Push(ctx context.Context) error
GetRepoInfo(ctx context.Context) (string, string, string, error)
+ GetCurrentBranch(ctx context.Context) (string, error)
+ FetchTags(ctx context.Context) error
+ ValidateTagExists(ctx context.Context, tag string) error
}
type ReleaseService struct {
@@ -38,6 +63,12 @@ type ReleaseService struct {
notesGen ports.ReleaseNotesGenerator
depAnalyzer *dependency.AnalyzerRegistry
config *config.Config
+
+ versionFileCache struct {
+ file string
+ pattern string
+ lang string
+ }
}
type ReleaseOption func(*ReleaseService)
@@ -75,6 +106,14 @@ func NewReleaseService(
}
func (s *ReleaseService) AnalyzeNextRelease(ctx context.Context) (*models.Release, error) {
+ log := logger.FromContext(ctx)
+
+ if s.config != nil && s.config.AutoFetchTags {
+ if err := s.git.FetchTags(ctx); err != nil {
+ log.Warn("failed to fetch tags, continuing with local tags", "error", err)
+ }
+ }
+
lastTag, err := s.git.GetLastTag(ctx)
if err != nil {
return nil, domainErrors.NewAppError(domainErrors.TypeGit, "error getting last tag", err)
@@ -86,6 +125,12 @@ func (s *ReleaseService) AnalyzeNextRelease(ctx context.Context) (*models.Releas
return nil, domainErrors.NewAppError(domainErrors.TypeGit, "no commits found in repository", nil)
}
lastTag = "v0.0.0"
+ log.Info("no previous tag found, using v0.0.0 as baseline")
+ } else {
+ if !regex.SemVer.MatchString(lastTag) {
+ log.Warn("last tag does not match semver format", "tag", lastTag)
+ return nil, fmt.Errorf("%w: tag '%s'", domainErrors.ErrInvalidTagFormat, lastTag)
+ }
}
commits, err := s.git.GetCommitsSinceTag(ctx, lastTag)
@@ -97,6 +142,12 @@ func (s *ReleaseService) AnalyzeNextRelease(ctx context.Context) (*models.Releas
return nil, domainErrors.ErrNoChanges
}
+ validCommits := s.filterValidCommits(commits)
+ if len(validCommits) == 0 && len(commits) > 0 {
+ log.Warn("no conventional commits found, but commits exist",
+ "total_commits", len(commits))
+ }
+
release := &models.Release{
PreviousVersion: lastTag,
AllCommits: commits,
@@ -108,6 +159,10 @@ func (s *ReleaseService) AnalyzeNextRelease(ctx context.Context) (*models.Releas
release.Version = newVersion
release.VersionBump = bump
+ if err := s.validateVersionIncrement(lastTag, newVersion); err != nil {
+ log.Warn("version increment validation failed", "error", err)
+ }
+
return release, nil
}
@@ -614,50 +669,724 @@ func (s *ReleaseService) PushChanges(ctx context.Context) error {
return s.git.Push(ctx)
}
-func (s *ReleaseService) UpdateAppVersion(version string) error {
- mainGoFile := "cmd/main.go"
- versionPattern := `Version:\s*".*"`
+func (s *ReleaseService) UpdateAppVersion(ctx context.Context, version string) error {
+ log := logger.FromContext(ctx)
- if s.config != nil {
- if s.config.VersionFile != "" {
- mainGoFile = s.config.VersionFile
- }
- if s.config.VersionPattern != "" {
- versionPattern = s.config.VersionPattern
+ versionFile, versionPattern, err := s.FindVersionFile(ctx)
+ if err != nil {
+ log.Warn("could not auto-detect version file, using defaults", "error", err)
+ versionFile = "cmd/main.go"
+ versionPattern = `Version:\s*".*"`
+
+ if s.config != nil {
+ if s.config.VersionFile != "" {
+ versionFile = s.config.VersionFile
+ }
+ if s.config.VersionPattern != "" {
+ versionPattern = s.config.VersionPattern
+ }
}
}
- content, err := os.ReadFile(mainGoFile)
+ log.Debug("updating version",
+ "file", versionFile,
+ "pattern", versionPattern,
+ "new_version", version)
+
+ content, err := os.ReadFile(versionFile)
if err != nil {
- return domainErrors.NewAppError(domainErrors.TypeInternal, fmt.Sprintf("failed to read version file: %s", mainGoFile), err)
+ return domainErrors.NewAppError(domainErrors.TypeInternal,
+ fmt.Sprintf("failed to read version file: %s", versionFile), err)
}
re, err := regexp.Compile(versionPattern)
if err != nil {
- return domainErrors.NewAppError(domainErrors.TypeInternal, fmt.Sprintf("invalid version pattern: %s", versionPattern), err)
+ return domainErrors.NewAppError(domainErrors.TypeInternal,
+ fmt.Sprintf("invalid version pattern: %s", versionPattern), err)
}
currentContent := string(content)
if !re.MatchString(currentContent) {
- return domainErrors.NewAppError(domainErrors.TypeInternal, fmt.Sprintf("version pattern not found in %s", mainGoFile), nil)
+ return domainErrors.NewAppError(domainErrors.TypeInternal,
+ fmt.Sprintf("version pattern not found in %s with pattern %s", versionFile, versionPattern), nil)
}
match := re.FindString(currentContent)
+ if match == "" {
+ return domainErrors.NewAppError(domainErrors.TypeInternal,
+ "could not find version match", nil)
+ }
- valMatch := regex.QuotedString.FindStringIndex(match)
+ cleanVersion := strings.TrimPrefix(version, "v")
- if valMatch == nil {
- return domainErrors.NewAppError(domainErrors.TypeInternal, "could not find quoted string in matching pattern", nil)
+ var newMatch string
+ ext := filepath.Ext(versionFile)
+
+ switch ext {
+ case ".json":
+ versionRe := regexp.MustCompile(`"version"\s*:\s*"([^"]+)"`)
+ newMatch = versionRe.ReplaceAllString(match, fmt.Sprintf(`"version": "%s"`, cleanVersion))
+ case ".toml":
+ versionRe := regexp.MustCompile(`version\s*=\s*"([^"]+)"`)
+ newMatch = versionRe.ReplaceAllString(match, fmt.Sprintf(`version = "%s"`, cleanVersion))
+ case ".xml":
+ versionRe := regexp.MustCompile(`([^<]+)`)
+ newMatch = versionRe.ReplaceAllString(match, fmt.Sprintf(`%s`, cleanVersion))
+ case ".py":
+ if strings.Contains(match, `"`) {
+ versionRe := regexp.MustCompile(`"([^"]+)"`)
+ newMatch = versionRe.ReplaceAllString(match, fmt.Sprintf(`"%s"`, cleanVersion))
+ } else if strings.Contains(match, `'`) {
+ versionRe := regexp.MustCompile(`'([^']+)'`)
+ newMatch = versionRe.ReplaceAllString(match, fmt.Sprintf(`'%s'`, cleanVersion))
+ }
+ case ".go", ".js", ".ts", ".rs", ".php", ".rb":
+ if strings.Contains(match, `"`) {
+ valMatch := regex.QuotedString.FindStringIndex(match)
+ if valMatch != nil {
+ newMatch = match[:valMatch[0]] + fmt.Sprintf(`"%s"`, cleanVersion) + match[valMatch[1]:]
+ } else {
+ versionRe := regexp.MustCompile(`"([^"]+)"`)
+ newMatch = versionRe.ReplaceAllString(match, fmt.Sprintf(`"%s"`, cleanVersion))
+ }
+ } else if strings.Contains(match, `'`) {
+ versionRe := regexp.MustCompile(`'([^']+)'`)
+ newMatch = versionRe.ReplaceAllString(match, fmt.Sprintf(`'%s'`, cleanVersion))
+ } else {
+ versionRe := regexp.MustCompile(`[\d.]+`)
+ newMatch = versionRe.ReplaceAllString(match, cleanVersion)
+ }
+ default:
+ if strings.Contains(match, `"`) {
+ versionRe := regexp.MustCompile(`"([^"]+)"`)
+ newMatch = versionRe.ReplaceAllString(match, fmt.Sprintf(`"%s"`, cleanVersion))
+ } else {
+ versionRe := regexp.MustCompile(`[\d.]+`)
+ newMatch = versionRe.ReplaceAllString(match, cleanVersion)
+ }
}
- cleanVersion := strings.TrimPrefix(version, "v")
- newMatch := match[:valMatch[0]] + fmt.Sprintf(`"%s"`, cleanVersion) + match[valMatch[1]:]
+ if newMatch == "" {
+ newMatch = match
+ }
newContent := strings.Replace(currentContent, match, newMatch, 1)
- if err := os.WriteFile(mainGoFile, []byte(newContent), 0644); err != nil {
- return domainErrors.NewAppError(domainErrors.TypeInternal, fmt.Sprintf("failed to write version file: %s", mainGoFile), err)
+ if err := os.WriteFile(versionFile, []byte(newContent), 0644); err != nil {
+ return domainErrors.NewAppError(domainErrors.TypeInternal,
+ fmt.Sprintf("failed to write version file: %s", versionFile), err)
+ }
+
+ log.Info("version updated successfully",
+ "file", versionFile,
+ "version", version)
+
+ return nil
+}
+
+func (s *ReleaseService) FindVersionFile(ctx context.Context) (string, string, error) {
+ log := logger.FromContext(ctx)
+
+ if s.versionFileCache.file != "" && s.versionFileCache.pattern != "" {
+ log.Debug("using cached version file",
+ "file", s.versionFileCache.file,
+ "pattern", s.versionFileCache.pattern,
+ )
+ return s.versionFileCache.file, s.versionFileCache.pattern, nil
+ }
+
+ if s.config != nil && s.config.VersionFile != "" {
+ pattern := s.config.VersionPattern
+ if pattern == "" {
+ detectedPattern, err := s.detectPatternInFile(s.config.VersionFile)
+ if err == nil && detectedPattern != "" {
+ pattern = detectedPattern
+ } else {
+ pattern = `Version:\s*".*"`
+ }
+ }
+ log.Debug("using configured version file",
+ "file", s.config.VersionFile,
+ "pattern", pattern)
+
+ s.versionFileCache.file = s.config.VersionFile
+ s.versionFileCache.pattern = pattern
+
+ return s.config.VersionFile, pattern, nil
+ }
+
+ projectType := s.detectProjectType()
+ log.Debug("detected project type", "type", projectType)
+
+ if files, ok := versionFilesByLanguage[projectType]; ok {
+ for _, filePath := range files {
+ if strings.Contains(filePath, "*") {
+ matches, err := filepath.Glob(filePath)
+ if err == nil && len(matches) > 0 {
+ filePath = matches[0]
+ } else {
+ continue
+ }
+ }
+
+ if _, err := os.Stat(filePath); err == nil {
+ pattern, err := s.detectPatternInFileForLanguage(filePath, projectType)
+ if err == nil && pattern != "" {
+ log.Debug("version file found automatically",
+ "file", filePath,
+ "pattern", pattern,
+ "language", projectType,
+ )
+
+ s.versionFileCache.file = filePath
+ s.versionFileCache.pattern = pattern
+ s.versionFileCache.lang = projectType
+ return filePath, pattern, nil
+ }
+ }
+ }
+ }
+
+ foundFile, foundPattern, err := s.searchVersionFileRecursive(projectType)
+ if err == nil && foundFile != "" {
+ log.Debug("version file found recursively",
+ "file", foundFile,
+ "pattern", foundPattern,
+ )
+
+ s.versionFileCache.file = foundFile
+ s.versionFileCache.pattern = foundPattern
+ s.versionFileCache.lang = projectType
+
+ return foundFile, foundPattern, nil
+ }
+
+ return "", "", fmt.Errorf("could not find version file for project type: %s", projectType)
+}
+
+func (s *ReleaseService) ValidateMainBranch(ctx context.Context) error {
+ log := logger.FromContext(ctx)
+
+ branch, err := s.git.GetCurrentBranch(ctx)
+ if err != nil {
+ return domainErrors.NewAppError(domainErrors.TypeGit, "error getting current branch", err)
+ }
+
+ if branch != "main" && branch != "master" {
+ return fmt.Errorf("%w: currently on '%s'", domainErrors.ErrInvalidBranch, branch)
}
+ log.Debug("branch validation passed",
+ "branch", branch,
+ )
+ return nil
+}
+
+func (s *ReleaseService) filterValidCommits(commits []models.Commit) []models.Commit {
+ var valid []models.Commit
+ for _, commit := range commits {
+ if regex.ConventionalCommit.MatchString(commit.Message) {
+ valid = append(valid, commit)
+ }
+ }
+ return valid
+}
+
+func (s *ReleaseService) validateVersionIncrement(oldVersion, newVersion string) error {
+ oldClean := strings.TrimPrefix(oldVersion, "v")
+ newClean := strings.TrimPrefix(newVersion, "v")
+
+ if semver.Compare("v"+oldClean, "v"+newClean) >= 0 {
+ return fmt.Errorf("new version %s must be greater than previous version %s", newVersion, oldVersion)
+ }
+ return nil
+}
+
+func (s *ReleaseService) validateVersionString(versionStr string) error {
+ cleanVersion := strings.TrimPrefix(versionStr, "v")
+
+ if !semver.IsValid("v" + cleanVersion) {
+ return fmt.Errorf("invalid semver format: %s", versionStr)
+ }
return nil
}
+
+type versionPatternInfo struct {
+ name string
+ detectionPattern string
+ replacementPattern string
+}
+
+var consolidatedVersionPatterns = map[string][]versionPatternInfo{
+ "go": {
+ {
+ name: "const Version",
+ detectionPattern: `const\s+Version\s*=\s*"([^"]+)"`,
+ replacementPattern: `const\s+Version\s*=\s*"[^"]*"`,
+ },
+ {
+ name: "var Version",
+ detectionPattern: `var\s+Version\s*=\s*"([^"]+)"`,
+ replacementPattern: `var\s+Version\s*=\s*"[^"]*"`,
+ },
+ {
+ name: "const Version unquoted",
+ detectionPattern: `const\s+Version\s*=\s*([\d.]+)`,
+ replacementPattern: `const\s+Version\s*=\s*[\d.]+`,
+ },
+ {
+ name: "var Version unquoted",
+ detectionPattern: `var\s+Version\s*=\s*([\d.]+)`,
+ replacementPattern: `var\s+Version\s*=\s*[\d.]+`,
+ },
+ {
+ name: "Version:",
+ detectionPattern: `Version:\s*"([^"]+)"`,
+ replacementPattern: `Version:\s*"[^"]*"`,
+ },
+ {
+ name: "Version =",
+ detectionPattern: `Version\s*=\s*"([^"]+)"`,
+ replacementPattern: `Version\s*=\s*"[^"]*"`,
+ },
+ },
+ "python": {
+ {
+ name: "__version__ double quotes",
+ detectionPattern: `__version__\s*=\s*"([^"]+)"`,
+ replacementPattern: `__version__\s*=\s*"[^"]*"`,
+ },
+ {
+ name: "__version__ single quotes",
+ detectionPattern: `__version__\s*=\s*'([^']+)'`,
+ replacementPattern: `__version__\s*=\s*'[^']*'`,
+ },
+ {
+ name: "version double quotes",
+ detectionPattern: `version\s*=\s*"([^"]+)"`,
+ replacementPattern: `version\s*=\s*"[^"]*"`,
+ },
+ {
+ name: "version any quotes",
+ detectionPattern: `version\s*=\s*['"]([^'"]+)['"]`,
+ replacementPattern: `version\s*=\s*['"][^'"]*['"]`,
+ },
+ {
+ name: "VERSION",
+ detectionPattern: `VERSION\s*=\s*"([^"]+)"`,
+ replacementPattern: `VERSION\s*=\s*"[^"]*"`,
+ },
+ },
+ "js": {
+ {
+ name: "version in JSON",
+ detectionPattern: `"version"\s*:\s*"([^"]+)"`,
+ replacementPattern: `"version"\s*:\s*"[^"]*"`,
+ },
+ {
+ name: "version in JSON single quotes",
+ detectionPattern: `'version'\s*:\s*'([^']+)'`,
+ replacementPattern: `'version'\s*:\s*'[^']*'`,
+ },
+ {
+ name: "export const version",
+ detectionPattern: `export\s+const\s+version\s*=\s*"([^"]+)"`,
+ replacementPattern: `export\s+const\s+version\s*=\s*"[^"]*"`,
+ },
+ {
+ name: "export const version single quotes",
+ detectionPattern: `export\s+const\s+version\s*=\s*'([^']+)'`,
+ replacementPattern: `export\s+const\s+version\s*=\s*'[^']*'`,
+ },
+ },
+ "rust": {
+ {
+ name: "version in TOML",
+ detectionPattern: `version\s*=\s*"([^"]+)"`,
+ replacementPattern: `version\s*=\s*"[^"]*"`,
+ },
+ },
+ "java": {
+ {
+ name: "version in XML",
+ detectionPattern: `([^<]+)`,
+ replacementPattern: `[^<]+`,
+ },
+ {
+ name: "version in properties",
+ detectionPattern: `version\s*=\s*['"]([^'"]+)['"]`,
+ replacementPattern: `version\s*=\s*['"][^'"]*['"]`,
+ },
+ },
+ "csharp": {
+ {
+ name: "AssemblyVersion",
+ detectionPattern: `AssemblyVersion\s*\(\s*"([^"]+)"`,
+ replacementPattern: `AssemblyVersion\s*\(\s*"[^"]*"`,
+ },
+ {
+ name: "Version in XML",
+ detectionPattern: `([^<]+)`,
+ replacementPattern: `[^<]+`,
+ },
+ {
+ name: "version in JSON",
+ detectionPattern: `"version"\s*:\s*"([^"]+)"`,
+ replacementPattern: `"version"\s*:\s*"[^"]*"`,
+ },
+ },
+ "php": {
+ {
+ name: "version in JSON",
+ detectionPattern: `"version"\s*:\s*"([^"]+)"`,
+ replacementPattern: `"version"\s*:\s*"[^"]*"`,
+ },
+ {
+ name: "const VERSION",
+ detectionPattern: `const\s+VERSION\s*=\s*['"]([^'"]+)['"]`,
+ replacementPattern: `const\s+VERSION\s*=\s*['"][^'"]*['"]`,
+ },
+ },
+ "ruby": {
+ {
+ name: "VERSION",
+ detectionPattern: `VERSION\s*=\s*['"]([^'"]+)['"]`,
+ replacementPattern: `VERSION\s*=\s*['"][^'"]*['"]`,
+ },
+ {
+ name: ".version",
+ detectionPattern: `\.version\s*=\s*['"]([^'"]+)['"]`,
+ replacementPattern: `\.version\s*=\s*['"][^'"]*['"]`,
+ },
+ },
+}
+
+func (s *ReleaseService) detectPatternInFile(filePath string) (string, error) {
+ content, err := os.ReadFile(filePath)
+ if err != nil {
+ return "", err
+ }
+
+ fileContent := string(content)
+ lang := detectLanguageFromFile(filePath)
+
+ if patterns, ok := consolidatedVersionPatterns[lang]; ok {
+ for _, patternInfo := range patterns {
+ re, err := regexp.Compile(patternInfo.detectionPattern)
+ if err != nil {
+ continue
+ }
+ if re.MatchString(fileContent) {
+ matches := re.FindStringSubmatch(fileContent)
+ if len(matches) > 1 {
+ if err := s.validateVersionString(matches[1]); err != nil {
+ continue
+ }
+ }
+ return patternInfo.replacementPattern, nil
+ }
+ }
+ }
+
+ for _, patterns := range consolidatedVersionPatterns {
+ for _, patternInfo := range patterns {
+ re, err := regexp.Compile(patternInfo.detectionPattern)
+ if err != nil {
+ continue
+ }
+ if re.MatchString(fileContent) {
+ matches := re.FindStringSubmatch(fileContent)
+ if len(matches) > 1 {
+ if err := s.validateVersionString(matches[1]); err != nil {
+ continue
+ }
+ }
+ return patternInfo.replacementPattern, nil
+ }
+ }
+ }
+
+ return "", fmt.Errorf("no version pattern found in file")
+}
+
+func (s *ReleaseService) searchVersionFileRecursive(lang string) (string, string, error) {
+ searchDirs := map[string][]string{
+ "go": {"internal", "pkg", "version", "cmd"},
+ "python": {"src", "lib", "."},
+ "js": {"src", "lib", "."},
+ "rust": {"."},
+ "java": {"src", "."},
+ "csharp": {"Properties", "."},
+ "php": {"src", "."},
+ "ruby": {"lib", "."},
+ }
+
+ dirs, ok := searchDirs[lang]
+ if !ok {
+ dirs = []string{"."}
+ }
+
+ extensions := map[string][]string{
+ "go": {".go"},
+ "python": {".py"},
+ "js": {".js", ".ts"},
+ "rust": {".rs", ".toml"},
+ "java": {".xml", ".gradle", ".properties"},
+ "csharp": {".cs", ".csproj", ".props"},
+ "php": {".php", ".json"},
+ "ruby": {".rb", ".gemspec"},
+ }
+
+ exts, ok := extensions[lang]
+ if !ok {
+ exts = []string{""}
+ }
+
+ filesScanned := 0
+
+ for _, dir := range dirs {
+ var foundPath string
+ var foundPattern string
+
+ err := filepath.Walk(dir, func(path string, info os.FileInfo, err error) error {
+ if err != nil {
+ return nil
+ }
+
+ if filesScanned >= maxFilesToScan {
+ return filepath.SkipDir
+ }
+
+ if info.IsDir() {
+ if ignoreDirs[info.Name()] {
+ return filepath.SkipDir
+ }
+
+ depth := len(strings.Split(path, string(os.PathSeparator)))
+ if depth > maxSearchDepth {
+ return filepath.SkipDir
+ }
+ return nil
+ }
+
+ filesScanned++
+
+ hasValidExt := false
+ for _, ext := range exts {
+ if ext == "" || strings.HasSuffix(path, ext) {
+ hasValidExt = true
+ break
+ }
+ }
+ if !hasValidExt {
+ return nil
+ }
+
+ if strings.Contains(strings.ToLower(path), "version") ||
+ strings.Contains(strings.ToLower(info.Name()), "version") ||
+ path == "package.json" || path == "Cargo.toml" || path == "setup.py" {
+ pattern, err := s.detectPatternInFileForLanguage(path, lang)
+ if err == nil && pattern != "" {
+ foundPath = path
+ foundPattern = pattern
+ return fmt.Errorf("found: %s", path)
+ }
+ }
+ return nil
+ })
+
+ if err != nil && strings.HasPrefix(err.Error(), "found: ") {
+ return foundPath, foundPattern, nil
+ }
+ }
+
+ return "", "", fmt.Errorf("version file not found")
+}
+
+func (s *ReleaseService) adjustPatternForReplacement(basePattern, content, lang string) string {
+ patterns, ok := consolidatedVersionPatterns[lang]
+ if !ok {
+ return basePattern
+ }
+
+ for _, patternInfo := range patterns {
+ re := regexp.MustCompile(patternInfo.detectionPattern)
+ if re.MatchString(content) {
+ return patternInfo.replacementPattern
+ }
+ }
+
+ return basePattern
+}
+
+func (s *ReleaseService) detectProjectType() string {
+ indicators := map[string][]string{
+ "go": {"go.mod", "Gopkg.toml", "glide.yaml"},
+ "python": {"setup.py", "pyproject.toml", "requirements.txt", "Pipfile"},
+ "js": {"package.json", "yarn.lock", "package-lock.json"},
+ "rust": {"Cargo.toml"},
+ "java": {"pom.xml", "build.gradle", "build.gradle.kts"},
+ "csharp": {".csproj", ".sln", "project.json"},
+ "php": {"composer.json"},
+ "ruby": {"Gemfile", "Rakefile"},
+ }
+
+ for lang, files := range indicators {
+ for _, file := range files {
+ if _, err := os.Stat(file); err == nil {
+ return lang
+ }
+ }
+ }
+
+ if hasFilesWithExtension(".go") {
+ return "go"
+ }
+ if hasFilesWithExtension(".py") {
+ return "python"
+ }
+ if hasFilesWithExtension(".js") || hasFilesWithExtension(".ts") {
+ return "js"
+ }
+ if hasFilesWithExtension(".rs") {
+ return "rust"
+ }
+
+ return "unknown"
+}
+
+func hasFilesWithExtension(ext string) bool {
+ entries, err := os.ReadDir(".")
+ if err != nil {
+ return false
+ }
+ for _, entry := range entries {
+ if !entry.IsDir() && strings.HasSuffix(entry.Name(), ext) {
+ return true
+ }
+ }
+ return false
+}
+
+var versionFilesByLanguage = map[string][]string{
+ "go": {
+ "internal/version/version.go",
+ "pkg/version/version.go",
+ "version/version.go",
+ "cmd/main.go",
+ "main.go",
+ "internal/version.go",
+ "pkg/version.go",
+ },
+ "python": {
+ "__version__.py",
+ "version.py",
+ "src/*/__version__.py",
+ "setup.py",
+ "pyproject.toml",
+ },
+ "js": {
+ "package.json",
+ "src/version.js",
+ "src/version.ts",
+ "lib/version.js",
+ },
+ "rust": {
+ "Cargo.toml",
+ },
+ "java": {
+ "pom.xml",
+ "build.gradle",
+ "build.gradle.kts",
+ "src/main/resources/version.properties",
+ },
+ "csharp": {
+ "Properties/AssemblyInfo.cs",
+ "Directory.Build.props",
+ "*.csproj",
+ },
+ "php": {
+ "composer.json",
+ "src/Version.php",
+ },
+ "ruby": {
+ "lib/*/version.rb",
+ "version.rb",
+ "*.gemspec",
+ },
+}
+
+func (s *ReleaseService) detectPatternInFileForLanguage(filePath, lang string) (string, error) {
+ content, err := os.ReadFile(filePath)
+ if err != nil {
+ return "", err
+ }
+
+ fileContent := string(content)
+ patterns, ok := consolidatedVersionPatterns[lang]
+ if !ok {
+ return s.detectPatternInFile(filePath)
+ }
+
+ for _, patternInfo := range patterns {
+ re, err := regexp.Compile(patternInfo.detectionPattern)
+ if err != nil {
+ continue
+ }
+ if re.MatchString(fileContent) {
+ matches := re.FindStringSubmatch(fileContent)
+ if len(matches) > 1 {
+ versionValue := matches[1]
+ if err := s.validateVersionString(versionValue); err != nil {
+ continue
+ }
+ }
+ return patternInfo.replacementPattern, nil
+ }
+ }
+
+ return "", fmt.Errorf("no version pattern found in file")
+}
+
+func detectLanguageFromFile(filePath string) string {
+ ext := filepath.Ext(filePath)
+ extToLang := map[string]string{
+ ".go": "go",
+ ".py": "python",
+ ".js": "js",
+ ".ts": "js",
+ ".rs": "rust",
+ ".toml": "rust",
+ ".xml": "java",
+ ".cs": "csharp",
+ ".csproj": "csharp",
+ ".props": "csharp",
+ ".php": "php",
+ ".rb": "ruby",
+ ".gemspec": "ruby",
+ ".json": "js",
+ }
+
+ filename := strings.ToLower(filepath.Base(filePath))
+ if filename == "package.json" || filename == "package-lock.json" {
+ return "js"
+ }
+ if filename == "composer.json" {
+ return "php"
+ }
+ if filename == "cargo.toml" {
+ return "rust"
+ }
+ if filename == "pom.xml" {
+ return "java"
+ }
+ if filename == "setup.py" || filename == "pyproject.toml" {
+ return "python"
+ }
+
+ if lang, ok := extToLang[ext]; ok {
+ return lang
+ }
+
+ return "unknown"
+}
diff --git a/internal/services/release_service_test.go b/internal/services/release_service_test.go
index a3ec1b1..5f2f746 100644
--- a/internal/services/release_service_test.go
+++ b/internal/services/release_service_test.go
@@ -8,11 +8,11 @@ import (
"strings"
"testing"
- "github.com/thomas-vilte/matecommit/internal/config"
- "github.com/thomas-vilte/matecommit/internal/models"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
"github.com/stretchr/testify/require"
+ "github.com/thomas-vilte/matecommit/internal/config"
+ "github.com/thomas-vilte/matecommit/internal/models"
)
func TestReleaseService_AnalyzeNextRelease(t *testing.T) {
@@ -462,6 +462,7 @@ func TestReleaseService_UpdateAppVersion(t *testing.T) {
cmdDir := filepath.Join(dir, "cmd")
err := os.MkdirAll(cmdDir, 0755)
require.NoError(t, err)
+ ctx := context.Background()
mainGoPath := filepath.Join(cmdDir, "main.go")
initialContent := `package main
@@ -478,7 +479,7 @@ var (
service := NewReleaseService(nil, WithReleaseConfig(cfg))
- err = service.UpdateAppVersion("v1.1.0")
+ err = service.UpdateAppVersion(ctx, "v1.1.0")
assert.NoError(t, err)
newContent, err := os.ReadFile(mainGoPath)
@@ -490,6 +491,7 @@ var (
t.Run("should update version with custom pattern", func(t *testing.T) {
dir := t.TempDir()
versionFile := filepath.Join(dir, "version.go")
+ ctx := context.Background()
initialContent := `package version
const CurrentVersion = "0.0.1"
@@ -504,7 +506,7 @@ const CurrentVersion = "0.0.1"
service := NewReleaseService(nil, WithReleaseConfig(cfg))
- err = service.UpdateAppVersion("v0.0.2")
+ err = service.UpdateAppVersion(ctx, "v0.0.2")
assert.NoError(t, err)
newContent, err := os.ReadFile(versionFile)
@@ -515,6 +517,7 @@ const CurrentVersion = "0.0.1"
t.Run("should fail if pattern not found", func(t *testing.T) {
dir := t.TempDir()
+ ctx := context.Background()
versionFile := filepath.Join(dir, "version.go")
err := os.WriteFile(versionFile, []byte("package version\n"), 0644)
require.NoError(t, err)
@@ -522,7 +525,7 @@ const CurrentVersion = "0.0.1"
cfg := &config.Config{VersionFile: versionFile}
service := NewReleaseService(nil, WithReleaseConfig(cfg))
- err = service.UpdateAppVersion("v1.0.0")
+ err = service.UpdateAppVersion(ctx, "v1.0.0")
assert.Error(t, err)
})
}
@@ -634,3 +637,843 @@ func TestReleaseService_PrependToChangelog(t *testing.T) {
assert.True(t, strings.HasPrefix(string(content), "# Changelog"))
})
}
+
+func TestReleaseService_FindVersionFile_RealScenarios(t *testing.T) {
+ t.Run("Go project with standard layout", func(t *testing.T) {
+ dir := t.TempDir()
+ origDir, _ := os.Getwd()
+ defer func() {
+ if err := os.Chdir(origDir); err != nil {
+ t.Fatal(err)
+ }
+ }()
+
+ goModPath := filepath.Join(dir, "go.mod")
+ err := os.WriteFile(goModPath, []byte("module test\n"), 0644)
+ require.NoError(t, err)
+
+ versionDir := filepath.Join(dir, "internal", "version")
+ err = os.MkdirAll(versionDir, 0755)
+ require.NoError(t, err)
+
+ versionFile := filepath.Join(versionDir, "version.go")
+ versionContent := `package version
+
+const Version = "1.0.0"
+`
+ err = os.WriteFile(versionFile, []byte(versionContent), 0644)
+ require.NoError(t, err)
+
+ err = os.Chdir(dir)
+ require.NoError(t, err)
+
+ service := &ReleaseService{}
+ foundFile, pattern, err := service.FindVersionFile(context.Background())
+
+ assert.NoError(t, err)
+ assert.Equal(t, "internal/version/version.go", foundFile)
+ assert.Contains(t, pattern, "Version")
+ })
+
+ t.Run("Python project with setup.py", func(t *testing.T) {
+ dir := t.TempDir()
+ origDir, _ := os.Getwd()
+ defer func() {
+ if err := os.Chdir(origDir); err != nil {
+ t.Fatal(err)
+ }
+ }()
+ setupPy := filepath.Join(dir, "setup.py")
+ setupContent := `from setuptools import setup
+
+setup(
+ name="test",
+ version="0.1.0",
+)
+`
+ err := os.WriteFile(setupPy, []byte(setupContent), 0644)
+ require.NoError(t, err)
+
+ err = os.Chdir(dir)
+ require.NoError(t, err)
+
+ service := &ReleaseService{}
+ foundFile, pattern, err := service.FindVersionFile(context.Background())
+
+ assert.NoError(t, err)
+ assert.Equal(t, "setup.py", foundFile)
+ assert.Contains(t, pattern, "version")
+ })
+
+ t.Run("JavaScript project with package.json", func(t *testing.T) {
+ dir := t.TempDir()
+ origDir, _ := os.Getwd()
+ defer func() {
+ if err := os.Chdir(origDir); err != nil {
+ t.Fatal(err)
+ }
+ }()
+ packageJSON := filepath.Join(dir, "package.json")
+ packageContent := `{
+ "name": "test-package",
+ "version": "2.3.4",
+ "description": "Test package"
+}
+`
+ err := os.WriteFile(packageJSON, []byte(packageContent), 0644)
+ require.NoError(t, err)
+
+ err = os.Chdir(dir)
+ require.NoError(t, err)
+
+ service := &ReleaseService{}
+ foundFile, pattern, err := service.FindVersionFile(context.Background())
+
+ assert.NoError(t, err)
+ assert.Equal(t, "package.json", foundFile)
+ assert.Contains(t, pattern, "version")
+ })
+
+ t.Run("Rust project with Cargo.toml", func(t *testing.T) {
+ dir := t.TempDir()
+ origDir, _ := os.Getwd()
+ defer func() {
+ if err := os.Chdir(origDir); err != nil {
+ t.Fatal(err)
+ }
+ }()
+ cargoToml := filepath.Join(dir, "Cargo.toml")
+ cargoContent := `[package]
+name = "test"
+version = "0.5.0"
+edition = "2021"
+`
+ err := os.WriteFile(cargoToml, []byte(cargoContent), 0644)
+ require.NoError(t, err)
+
+ err = os.Chdir(dir)
+ require.NoError(t, err)
+
+ service := &ReleaseService{}
+ foundFile, pattern, err := service.FindVersionFile(context.Background())
+
+ assert.NoError(t, err)
+ assert.Equal(t, "Cargo.toml", foundFile)
+ assert.Contains(t, pattern, "version")
+ })
+
+ t.Run("Config-specified version file takes precedence", func(t *testing.T) {
+ dir := t.TempDir()
+ origDir, _ := os.Getwd()
+ defer func() {
+ if err := os.Chdir(origDir); err != nil {
+ t.Fatal(err)
+ }
+ }()
+ customVersion := filepath.Join(dir, "custom_version.go")
+ content := `package main
+
+var AppVersion = "3.0.0"
+`
+ err := os.WriteFile(customVersion, []byte(content), 0644)
+ require.NoError(t, err)
+
+ err = os.Chdir(dir)
+ require.NoError(t, err)
+
+ cfg := &config.Config{
+ VersionFile: customVersion,
+ VersionPattern: `AppVersion\s*=\s*".*"`,
+ }
+ service := &ReleaseService{config: cfg}
+
+ foundFile, pattern, err := service.FindVersionFile(context.Background())
+
+ assert.NoError(t, err)
+ assert.Equal(t, customVersion, foundFile)
+ assert.Equal(t, `AppVersion\s*=\s*".*"`, pattern)
+ })
+
+ t.Run("Recursive search finds version file in nested directory", func(t *testing.T) {
+ dir := t.TempDir()
+ origDir, _ := os.Getwd()
+ defer func() {
+ if err := os.Chdir(origDir); err != nil {
+ t.Fatal(err)
+ }
+ }()
+ goModPath := filepath.Join(dir, "go.mod")
+ err := os.WriteFile(goModPath, []byte("module test\n"), 0644)
+ require.NoError(t, err)
+
+ pkgVersionDir := filepath.Join(dir, "pkg", "version")
+ err = os.MkdirAll(pkgVersionDir, 0755)
+ require.NoError(t, err)
+
+ versionFile := filepath.Join(pkgVersionDir, "version.go")
+ content := `package version
+
+const Version = "1.2.3"
+`
+ err = os.WriteFile(versionFile, []byte(content), 0644)
+ require.NoError(t, err)
+
+ err = os.Chdir(dir)
+ require.NoError(t, err)
+
+ service := &ReleaseService{}
+ foundFile, pattern, err := service.FindVersionFile(context.Background())
+
+ assert.NoError(t, err)
+ assert.Equal(t, "pkg/version/version.go", foundFile)
+ assert.Contains(t, pattern, "Version")
+ })
+
+ t.Run("Error when no version file found", func(t *testing.T) {
+ dir := t.TempDir()
+ origDir, _ := os.Getwd()
+ defer func() {
+ if err := os.Chdir(origDir); err != nil {
+ t.Fatal(err)
+ }
+ }()
+ err := os.Chdir(dir)
+ require.NoError(t, err)
+
+ service := &ReleaseService{}
+ _, _, err = service.FindVersionFile(context.Background())
+
+ assert.Error(t, err)
+ assert.Contains(t, err.Error(), "could not find version file")
+ })
+}
+
+func TestReleaseService_DetectProjectType_RealScenarios(t *testing.T) {
+ tests := []struct {
+ name string
+ files map[string]string
+ expectedType string
+ }{
+ {
+ name: "Go project with go.mod",
+ files: map[string]string{
+ "go.mod": "module test",
+ },
+ expectedType: "go",
+ },
+ {
+ name: "Python project with requirements.txt",
+ files: map[string]string{
+ "requirements.txt": "flask==2.0.0",
+ },
+ expectedType: "python",
+ },
+ {
+ name: "JavaScript project with package.json",
+ files: map[string]string{
+ "package.json": `{"name": "test"}`,
+ },
+ expectedType: "js",
+ },
+ {
+ name: "Rust project with Cargo.toml",
+ files: map[string]string{
+ "Cargo.toml": "[package]",
+ },
+ expectedType: "rust",
+ },
+ {
+ name: "PHP project with composer.json",
+ files: map[string]string{
+ "composer.json": `{"name": "test/package"}`,
+ },
+ expectedType: "php",
+ },
+ {
+ name: "Ruby project with Gemfile",
+ files: map[string]string{
+ "Gemfile": "source 'https://rubygems.org'",
+ },
+ expectedType: "ruby",
+ },
+ {
+ name: "Go project detected by .go files",
+ files: map[string]string{
+ "main.go": "package main",
+ "util.go": "package util",
+ },
+ expectedType: "go",
+ },
+ {
+ name: "Python project detected by .py files",
+ files: map[string]string{
+ "app.py": "import flask",
+ "test.py": "import unittest",
+ },
+ expectedType: "python",
+ },
+ {
+ name: "Unknown project type",
+ files: map[string]string{},
+ expectedType: "unknown",
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ dir := t.TempDir()
+ origDir, _ := os.Getwd()
+ defer func() {
+ if err := os.Chdir(origDir); err != nil {
+ t.Fatal(err)
+ }
+ }()
+ for filename, content := range tt.files {
+ filePath := filepath.Join(dir, filename)
+ err := os.WriteFile(filePath, []byte(content), 0644)
+ require.NoError(t, err)
+ }
+
+ err := os.Chdir(dir)
+ require.NoError(t, err)
+
+ service := &ReleaseService{}
+ projectType := service.detectProjectType()
+
+ assert.Equal(t, tt.expectedType, projectType)
+ })
+ }
+}
+
+func TestReleaseService_ValidateMainBranch_RealScenarios(t *testing.T) {
+ t.Run("Valid main branch", func(t *testing.T) {
+ mockGit := new(MockGitService)
+ service := NewReleaseService(mockGit)
+
+ mockGit.On("GetCurrentBranch", mock.Anything).Return("main", nil)
+
+ err := service.ValidateMainBranch(context.Background())
+
+ assert.NoError(t, err)
+ mockGit.AssertExpectations(t)
+ })
+
+ t.Run("Valid master branch", func(t *testing.T) {
+ mockGit := new(MockGitService)
+ service := NewReleaseService(mockGit)
+
+ mockGit.On("GetCurrentBranch", mock.Anything).Return("master", nil)
+
+ err := service.ValidateMainBranch(context.Background())
+
+ assert.NoError(t, err)
+ mockGit.AssertExpectations(t)
+ })
+
+ t.Run("Invalid feature branch", func(t *testing.T) {
+ mockGit := new(MockGitService)
+ service := NewReleaseService(mockGit)
+
+ mockGit.On("GetCurrentBranch", mock.Anything).Return("feature/new-feature", nil)
+
+ err := service.ValidateMainBranch(context.Background())
+
+ assert.Error(t, err)
+ assert.Contains(t, err.Error(), "currently on 'feature/new-feature'")
+ mockGit.AssertExpectations(t)
+ })
+
+ t.Run("Invalid develop branch", func(t *testing.T) {
+ mockGit := new(MockGitService)
+ service := NewReleaseService(mockGit)
+
+ mockGit.On("GetCurrentBranch", mock.Anything).Return("develop", nil)
+
+ err := service.ValidateMainBranch(context.Background())
+
+ assert.Error(t, err)
+ assert.Contains(t, err.Error(), "currently on 'develop'")
+ mockGit.AssertExpectations(t)
+ })
+
+ t.Run("Git error getting branch", func(t *testing.T) {
+ mockGit := new(MockGitService)
+ service := NewReleaseService(mockGit)
+
+ mockGit.On("GetCurrentBranch", mock.Anything).Return("", errors.New("not a git repository"))
+
+ err := service.ValidateMainBranch(context.Background())
+
+ assert.Error(t, err)
+ assert.Contains(t, err.Error(), "error getting current branch")
+ mockGit.AssertExpectations(t)
+ })
+}
+
+func TestReleaseService_CommitChangelog_RealScenarios(t *testing.T) {
+ t.Run("Successfully commit changelog with version file", func(t *testing.T) {
+ dir := t.TempDir()
+ versionFile := filepath.Join(dir, "cmd", "main.go")
+ err := os.MkdirAll(filepath.Dir(versionFile), 0755)
+ require.NoError(t, err)
+
+ content := `package main
+const Version = "1.0.0"
+`
+ err = os.WriteFile(versionFile, []byte(content), 0644)
+ require.NoError(t, err)
+
+ mockGit := new(MockGitService)
+ cfg := &config.Config{VersionFile: versionFile}
+ service := NewReleaseService(mockGit, WithReleaseConfig(cfg))
+
+ mockGit.On("AddFileToStaging", mock.Anything, versionFile).Return(nil)
+ mockGit.On("HasStagedChanges", mock.Anything).Return(true)
+ mockGit.On("CreateCommit", mock.Anything, "chore: update changelog and bump version to v1.1.0").Return(nil)
+
+ err = service.CommitChangelog(context.Background(), "v1.1.0")
+
+ assert.NoError(t, err)
+ mockGit.AssertExpectations(t)
+ })
+
+ t.Run("Error when no staged changes", func(t *testing.T) {
+ mockGit := new(MockGitService)
+ service := NewReleaseService(mockGit)
+
+ mockGit.On("HasStagedChanges", mock.Anything).Return(false)
+
+ err := service.CommitChangelog(context.Background(), "v1.1.0")
+
+ assert.Error(t, err)
+ assert.Contains(t, err.Error(), "no staged changes detected")
+ mockGit.AssertExpectations(t)
+ })
+
+ t.Run("Skips missing version file and commits if other files staged", func(t *testing.T) {
+ mockGit := new(MockGitService)
+ cfg := &config.Config{VersionFile: "/non/existent/file.go"}
+ service := NewReleaseService(mockGit, WithReleaseConfig(cfg))
+
+ mockGit.On("HasStagedChanges", mock.Anything).Return(true)
+ mockGit.On("CreateCommit", mock.Anything, "chore: update changelog and bump version to v2.0.0").Return(nil)
+
+ err := service.CommitChangelog(context.Background(), "v2.0.0")
+
+ assert.NoError(t, err)
+ mockGit.AssertExpectations(t)
+ })
+
+ t.Run("Error adding file to staging", func(t *testing.T) {
+ dir := t.TempDir()
+ versionFile := filepath.Join(dir, "version.txt")
+ err := os.WriteFile(versionFile, []byte("1.0.0"), 0644)
+ require.NoError(t, err)
+
+ mockGit := new(MockGitService)
+ cfg := &config.Config{VersionFile: versionFile}
+ service := NewReleaseService(mockGit, WithReleaseConfig(cfg))
+
+ mockGit.On("AddFileToStaging", mock.Anything, versionFile).Return(errors.New("permission denied"))
+
+ err = service.CommitChangelog(context.Background(), "v1.1.0")
+
+ assert.Error(t, err)
+ assert.Contains(t, err.Error(), "failed to add version file to staging")
+ mockGit.AssertExpectations(t)
+ })
+
+ t.Run("Error creating commit", func(t *testing.T) {
+ mockGit := new(MockGitService)
+ service := NewReleaseService(mockGit)
+
+ mockGit.On("HasStagedChanges", mock.Anything).Return(true)
+ mockGit.On("CreateCommit", mock.Anything, mock.Anything).Return(errors.New("commit failed"))
+
+ err := service.CommitChangelog(context.Background(), "v1.0.0")
+
+ assert.Error(t, err)
+ assert.Contains(t, err.Error(), "failed to commit changelog and version bump")
+ mockGit.AssertExpectations(t)
+ })
+}
+
+func TestReleaseService_UpdateAppVersion_MultiLanguageRealScenarios(t *testing.T) {
+ tests := []struct {
+ name string
+ fileExt string
+ initialContent string
+ versionPattern string
+ expectedContent string
+ version string
+ }{
+ {
+ name: "Go file with const Version",
+ fileExt: ".go",
+ initialContent: `package version
+
+const Version = "1.0.0"
+`,
+ versionPattern: `const\s+Version\s*=\s*"[^"]*"`,
+ expectedContent: `const Version = "2.0.0"`,
+ version: "v2.0.0",
+ },
+ {
+ name: "Python file with __version__",
+ fileExt: ".py",
+ initialContent: `"""Version module"""
+
+__version__ = "0.1.0"
+`,
+ versionPattern: `__version__\s*=\s*"[^"]*"`,
+ expectedContent: `__version__ = "0.2.0"`,
+ version: "v0.2.0",
+ },
+ {
+ name: "JavaScript package.json",
+ fileExt: ".json",
+ initialContent: `{
+ "name": "test",
+ "version": "1.2.3",
+ "description": "Test"
+}`,
+ versionPattern: `"version"\s*:\s*"[^"]*"`,
+ expectedContent: `"version": "1.3.0"`,
+ version: "v1.3.0",
+ },
+ {
+ name: "Rust Cargo.toml",
+ fileExt: ".toml",
+ initialContent: `[package]
+name = "test"
+version = "0.1.0"
+edition = "2021"`,
+ versionPattern: `version\s*=\s*"[^"]*"`,
+ expectedContent: `version = "0.2.0"`,
+ version: "v0.2.0",
+ },
+ {
+ name: "Ruby version.rb",
+ fileExt: ".rb",
+ initialContent: `module MyGem
+ VERSION = "1.0.0"
+end`,
+ versionPattern: `VERSION\s*=\s*"[^"]*"`,
+ expectedContent: `VERSION = "1.1.0"`,
+ version: "v1.1.0",
+ },
+ {
+ name: "PHP composer.json",
+ fileExt: ".json",
+ initialContent: `{
+ "name": "vendor/package",
+ "version": "2.0.0"
+}`,
+ versionPattern: `"version"\s*:\s*"[^"]*"`,
+ expectedContent: `"version": "3.0.0"`,
+ version: "v3.0.0",
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ dir := t.TempDir()
+ versionFile := filepath.Join(dir, "version"+tt.fileExt)
+ err := os.WriteFile(versionFile, []byte(tt.initialContent), 0644)
+ require.NoError(t, err)
+
+ cfg := &config.Config{
+ VersionFile: versionFile,
+ VersionPattern: tt.versionPattern,
+ }
+ service := &ReleaseService{config: cfg}
+
+ err = service.UpdateAppVersion(context.Background(), tt.version)
+ assert.NoError(t, err)
+
+ updatedContent, err := os.ReadFile(versionFile)
+ require.NoError(t, err)
+
+ assert.Contains(t, string(updatedContent), tt.expectedContent)
+ })
+ }
+}
+
+func TestReleaseService_FilterValidCommits_RealScenarios(t *testing.T) {
+ service := &ReleaseService{}
+
+ tests := []struct {
+ name string
+ commits []models.Commit
+ expected int
+ }{
+ {
+ name: "All conventional commits",
+ commits: []models.Commit{
+ {Message: "feat: add feature"},
+ {Message: "fix: fix bug"},
+ {Message: "docs: update docs"},
+ },
+ expected: 3,
+ },
+ {
+ name: "Mixed conventional and non-conventional",
+ commits: []models.Commit{
+ {Message: "feat: add feature"},
+ {Message: "WIP: work in progress"},
+ {Message: "fix: fix bug"},
+ {Message: "random commit message"},
+ },
+ expected: 2,
+ },
+ {
+ name: "No conventional commits",
+ commits: []models.Commit{
+ {Message: "WIP"},
+ {Message: "update stuff"},
+ {Message: "changes"},
+ },
+ expected: 0,
+ },
+ {
+ name: "Conventional commits with scopes",
+ commits: []models.Commit{
+ {Message: "feat(api): add endpoint"},
+ {Message: "fix(ui): fix button"},
+ {Message: "refactor(core): improve logic"},
+ },
+ expected: 3,
+ },
+ {
+ name: "Breaking changes",
+ commits: []models.Commit{
+ {Message: "feat!: breaking change"},
+ {Message: "fix: normal fix"},
+ },
+ expected: 2,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ valid := service.filterValidCommits(tt.commits)
+ assert.Len(t, valid, tt.expected)
+ })
+ }
+}
+
+func TestReleaseService_PushChanges_RealScenarios(t *testing.T) {
+ t.Run("Successfully push changes", func(t *testing.T) {
+ mockGit := new(MockGitService)
+ service := NewReleaseService(mockGit)
+
+ mockGit.On("Push", mock.Anything).Return(nil)
+
+ err := service.PushChanges(context.Background())
+
+ assert.NoError(t, err)
+ mockGit.AssertExpectations(t)
+ })
+
+ t.Run("Error pushing to remote", func(t *testing.T) {
+ mockGit := new(MockGitService)
+ service := NewReleaseService(mockGit)
+
+ mockGit.On("Push", mock.Anything).Return(errors.New("failed to push: remote rejected"))
+
+ err := service.PushChanges(context.Background())
+
+ assert.Error(t, err)
+ assert.Contains(t, err.Error(), "remote rejected")
+ mockGit.AssertExpectations(t)
+ })
+}
+
+func TestReleaseService_UpdateLocalChangelog_RealScenarios(t *testing.T) {
+ t.Run("Update changelog with basic notes", func(t *testing.T) {
+ dir := t.TempDir()
+ origDir, _ := os.Getwd()
+ defer func() {
+ if err := os.Chdir(origDir); err != nil {
+ t.Fatal(err)
+ }
+ }()
+ err := os.Chdir(dir)
+ require.NoError(t, err)
+
+ mockGit := new(MockGitService)
+ service := NewReleaseService(mockGit)
+
+ release := &models.Release{
+ Version: "v1.1.0",
+ PreviousVersion: "v1.0.0",
+ }
+
+ notes := &models.ReleaseNotes{
+ Title: "Version 1.1.0",
+ Summary: "This release includes 2 new features",
+ Highlights: []string{
+ "Improved performance",
+ "Added new API endpoint",
+ },
+ }
+
+ mockGit.On("GetTagDate", mock.Anything, "v1.1.0").Return("2025-01-15", nil)
+ mockGit.On("GetRepoInfo", mock.Anything).Return("user", "repo", "github", nil)
+
+ err = service.UpdateLocalChangelog(release, notes)
+
+ assert.NoError(t, err)
+
+ changelogPath := filepath.Join(dir, "CHANGELOG.md")
+ content, err := os.ReadFile(changelogPath)
+ require.NoError(t, err)
+
+ assert.Contains(t, string(content), "## [v1.1.0]")
+ assert.Contains(t, string(content), "2025-01-15")
+ assert.Contains(t, string(content), "This release includes 2 new features")
+ assert.Contains(t, string(content), "Improved performance")
+ mockGit.AssertExpectations(t)
+ })
+
+ t.Run("Prepend to existing changelog", func(t *testing.T) {
+ dir := t.TempDir()
+ origDir, _ := os.Getwd()
+ defer func() {
+ if err := os.Chdir(origDir); err != nil {
+ t.Fatal(err)
+ }
+ }()
+ changelogPath := filepath.Join(dir, "CHANGELOG.md")
+ existingContent := `# Changelog
+
+## [v1.0.0] - 2025-01-01
+
+Initial release
+`
+ err := os.WriteFile(changelogPath, []byte(existingContent), 0644)
+ require.NoError(t, err)
+
+ err = os.Chdir(dir)
+ require.NoError(t, err)
+
+ mockGit := new(MockGitService)
+ service := NewReleaseService(mockGit)
+
+ release := &models.Release{
+ Version: "v1.1.0",
+ PreviousVersion: "v1.0.0",
+ }
+
+ notes := &models.ReleaseNotes{
+ Title: "Version 1.1.0",
+ Summary: "Bug fixes and improvements",
+ }
+
+ mockGit.On("GetTagDate", mock.Anything, "v1.1.0").Return("2025-01-15", nil)
+ mockGit.On("GetRepoInfo", mock.Anything).Return("", "", "", errors.New("not a repo"))
+
+ err = service.UpdateLocalChangelog(release, notes)
+
+ assert.NoError(t, err)
+
+ content, err := os.ReadFile(changelogPath)
+ require.NoError(t, err)
+
+ contentStr := string(content)
+ assert.Contains(t, contentStr, "## [v1.1.0]")
+ assert.Contains(t, contentStr, "## [v1.0.0]")
+ v110Pos := strings.Index(contentStr, "## [v1.1.0]")
+ v100Pos := strings.Index(contentStr, "## [v1.0.0]")
+ assert.Less(t, v110Pos, v100Pos, "New version should come before old version")
+ mockGit.AssertExpectations(t)
+ })
+}
+
+func TestReleaseService_FindVersionFile_OptimizationAndValidation(t *testing.T) {
+ t.Run("Should ignore invalid semver versions", func(t *testing.T) {
+ dir := t.TempDir()
+ versionFile := filepath.Join(dir, "version.go")
+ content := `package main
+const Version = "dev"
+`
+ err := os.WriteFile(versionFile, []byte(content), 0644)
+ require.NoError(t, err)
+
+ service := &ReleaseService{}
+
+ cwd, _ := os.Getwd()
+ defer os.Chdir(cwd)
+ os.Chdir(dir)
+
+ os.WriteFile("go.mod", []byte("module test"), 0644)
+
+ foundFile, pattern, err := service.FindVersionFile(context.Background())
+ assert.Error(t, err)
+ assert.Equal(t, "", foundFile)
+ assert.Equal(t, "", pattern)
+ })
+
+ t.Run("Should ignore node_modules", func(t *testing.T) {
+ dir := t.TempDir()
+ cwd, _ := os.Getwd()
+ defer os.Chdir(cwd)
+ os.Chdir(dir)
+
+ os.WriteFile("package.json", []byte(`{"name":"test"}`), 0644)
+
+ nodeModules := filepath.Join(dir, "node_modules")
+ os.Mkdir(nodeModules, 0755)
+
+ ignoredFile := filepath.Join(nodeModules, "package.json")
+ os.WriteFile(ignoredFile, []byte(`{"version": "1.0.0"}`), 0644)
+
+ service := &ReleaseService{}
+ foundFile, _, err := service.FindVersionFile(context.Background())
+
+ assert.Error(t, err)
+ assert.NotContains(t, foundFile, "node_modules")
+ })
+
+ t.Run("Should respect max recursion depth", func(t *testing.T) {
+ dir := t.TempDir()
+ cwd, _ := os.Getwd()
+ defer os.Chdir(cwd)
+ os.Chdir(dir)
+ os.WriteFile("go.mod", []byte("module test"), 0644)
+
+ deepDir := filepath.Join(dir, "1", "2", "3", "4", "5", "6")
+ os.MkdirAll(deepDir, 0755)
+
+ versionFile := filepath.Join(deepDir, "version.go")
+ os.WriteFile(versionFile, []byte(`package main
+const Version = "1.0.0"`), 0644)
+
+ service := &ReleaseService{}
+ foundFile, _, err := service.FindVersionFile(context.Background())
+
+ assert.Error(t, err)
+ assert.Equal(t, "", foundFile)
+ })
+
+ t.Run("Should find file within recursion depth", func(t *testing.T) {
+ dir := t.TempDir()
+ cwd, _ := os.Getwd()
+ defer os.Chdir(cwd)
+ os.Chdir(dir)
+ os.WriteFile("go.mod", []byte("module test"), 0644)
+
+ shallowDir := filepath.Join(dir, "internal")
+ os.MkdirAll(shallowDir, 0755)
+
+ versionFile := filepath.Join(shallowDir, "version.go")
+ os.WriteFile(versionFile, []byte(`package main
+const Version = "1.0.0"`), 0644)
+
+ service := &ReleaseService{}
+ foundFile, _, err := service.FindVersionFile(context.Background())
+
+ assert.NoError(t, err)
+ assert.Contains(t, foundFile, "version.go")
+ })
+}
diff --git a/internal/ui/ui.go b/internal/ui/ui.go
index 6edd957..3e5452b 100644
--- a/internal/ui/ui.go
+++ b/internal/ui/ui.go
@@ -26,7 +26,6 @@ var (
// Emojis with colors
SuccessEmoji = Success.Sprint("✓")
- ErrorEmoji = Error.Sprint("✗")
WarningEmoji = Warning.Sprint("⚠")
InfoEmoji = Info.Sprint("*")
RocketEmoji = Accent.Sprint("🚀")
@@ -234,6 +233,9 @@ func HandleAppError(err error, t *i18n.Translations) {
if errors.Is(appErr, domainErrors.ErrNoChanges) {
msg = t.GetMessage("ui_error.no_changes_detected", 0, nil)
suggestion = t.GetMessage("ui_error.ensure_modified_files", 0, nil)
+ } else if errors.Is(appErr, domainErrors.ErrNoDiff) {
+ msg = t.GetMessage("ui_error.no_changes_detected", 0, nil)
+ suggestion = t.GetMessage("ui_error.ensure_modified_files", 0, nil)
} else if errors.Is(appErr, domainErrors.ErrGitUserNotConfigured) {
msg = t.GetMessage("ui_error.git_user_not_configured", 0, nil)
suggestion = t.GetMessage("ui_error.git_config_user_suggestion", 0, nil)
@@ -243,11 +245,27 @@ func HandleAppError(err error, t *i18n.Translations) {
} else if errors.Is(appErr, domainErrors.ErrNotInGitRepo) {
msg = t.GetMessage("ui_error.not_in_git_repo", 0, nil)
suggestion = t.GetMessage("ui_error.git_init_suggestion", 0, nil)
+ } else if errors.Is(appErr, domainErrors.ErrInvalidBranch) {
+ msg = appErr.Message
+ suggestion = "Switch to main or master branch to create releases"
+ } else if errors.Is(appErr, domainErrors.ErrTagNotFound) {
+ msg = appErr.Message
+ if tagCtx, ok := appErr.Context["tag"].(string); ok {
+ suggestion = fmt.Sprintf("Tag '%s' not found in repository. Check available tags with: git tag -l", tagCtx)
+ }
+ } else if errors.Is(appErr, domainErrors.ErrInvalidTagFormat) {
+ msg = appErr.Message
+ suggestion = "Tags must follow semantic versioning format (vX.Y.Z), e.g., v1.0.0"
} else {
msg = appErr.Message
if appErr.Err != nil {
suggestion = appErr.Err.Error()
}
+ if appErr.Context != nil {
+ if file, ok := appErr.Context["file"].(string); ok {
+ suggestion = fmt.Sprintf("File: %s - %s", file, suggestion)
+ }
+ }
}
case domainErrors.TypeAI:
if errors.Is(appErr, domainErrors.ErrGeminiAPIKeyInvalid) {