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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 13 additions & 1 deletion internal/commands/release/create.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,15 @@ package release
import (
"bufio"
"context"
"errors"
"fmt"
"os"
"strings"
"time"

"github.com/thomas-vilte/matecommit/internal/commands/completion_helper"
cfg "github.com/thomas-vilte/matecommit/internal/config"
domainErrors "github.com/thomas-vilte/matecommit/internal/errors"
"github.com/thomas-vilte/matecommit/internal/i18n"
"github.com/thomas-vilte/matecommit/internal/logger"
"github.com/thomas-vilte/matecommit/internal/models"
Expand Down Expand Up @@ -141,7 +143,17 @@ func createReleaseAction(releaseSvc releaseService, trans *i18n.Translations, re

sPush := ui.NewSmartSpinner(trans.GetMessage("release.pushing_changes", 0, nil))
sPush.Start()
if err := releaseSvc.PushChanges(ctx); err != nil {
if err := releaseSvc.PushChanges(ctx, release.Version); err != nil {
sPush.Stop()
if errors.Is(err, domainErrors.ErrReleasePROpened) {
// Not a failure: PushChanges couldn't push directly (the
// branch is ruleset-protected) but opened a PR instead.
// The release isn't finished — it needs a merge and a
// re-run — so this still surfaces as a non-zero exit,
// but ui.HandleAppError presents it as "action needed"
// (with the PR URL) rather than a generic push failure.
return ui.HandleAppError(err, trans)
}
sPush.Error(trans.GetMessage("release.error_pushing_changes", 0, struct{ Error string }{err.Error()}))
return fmt.Errorf("error pushing changes: %w", err)
}
Expand Down
2 changes: 1 addition & 1 deletion internal/commands/release/create_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -269,7 +269,7 @@ func TestCreateCommand_WithChangelog(t *testing.T) {
mockService.On("UpdateLocalChangelog", mock.Anything, release, notes).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)
mockService.On("PushChanges", mock.Anything, mock.Anything).Return(nil)

mockService.On("CreateTag", mock.Anything, "v1.0.0", mock.Anything).Return(nil)

Expand Down
4 changes: 2 additions & 2 deletions internal/commands/release/mocks_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -75,8 +75,8 @@ func (m *MockReleaseService) CommitChangelog(ctx context.Context, version string
return args.Error(0)
}

func (m *MockReleaseService) PushChanges(ctx context.Context) error {
args := m.Called(ctx)
func (m *MockReleaseService) PushChanges(ctx context.Context, version string) error {
args := m.Called(ctx, version)
return args.Error(0)
}

Expand Down
5 changes: 4 additions & 1 deletion internal/commands/release/release.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ type releaseService interface {
EnrichReleaseContext(ctx context.Context, release *models.Release) error
UpdateLocalChangelog(ctx context.Context, release *models.Release, notes *models.ReleaseNotes) error
CommitChangelog(ctx context.Context, version string) error
PushChanges(ctx context.Context) error
PushChanges(ctx context.Context, version string) error
UpdateAppVersion(ctx context.Context, version string) error
ValidateMainBranch(ctx context.Context) error
BuildChangelogPreview(ctx context.Context, release *models.Release, notes *models.ReleaseNotes) string
Expand All @@ -57,6 +57,9 @@ type gitService interface {
ValidateGitConfig(ctx context.Context) error
ValidateTagExists(ctx context.Context, tag string) error
GetRepoRoot(ctx context.Context) (string, error)
CreateAndSwitchBranch(ctx context.Context, branchName string) error
SwitchBranch(ctx context.Context, branchName string) error
PushBranch(ctx context.Context, branchName string) error
}

type ReleaseCommandFactory struct {
Expand Down
30 changes: 30 additions & 0 deletions internal/errors/errors.go
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,18 @@ func (e *AppError) Unwrap() error {
return e.Err
}

// Is lets errors.Is match against the package-level sentinel AppErrors
// (e.g. ErrPushRejectedByRuleset) by Type+Message, even though
// WithError/WithContext/WithSuggestion return a new *AppError instance
// each time rather than the same pointer.
func (e *AppError) Is(target error) bool {
t, ok := target.(*AppError)
if !ok {
return false
}
return e.Type == t.Type && e.Message == t.Message
}

// WithError creates a new AppError with an underlying error
func (e *AppError) WithError(err error) *AppError {
return &AppError{
Expand Down Expand Up @@ -132,6 +144,24 @@ var (
ErrPush = NewAppError(TypeGit, "Failed to push to remote", nil).
WithSuggestion("Verify remote is configured: git remote -v")

ErrPushRejectedByRuleset = NewAppError(TypeGit, "Push rejected by a GitHub branch/tag ruleset", nil).
WithSuggestion("This ref is protected — push your changes through a pull request instead, or ask a repo admin to adjust the ruleset in Settings > Rules")

// ErrReleasePROpened is not really a failure — it signals that PushChanges
// couldn't push directly (ruleset) but successfully opened a pull request
// as a fallback. Callers should check for it with errors.Is and present it
// as "action needed", not as a generic error.
ErrReleasePROpened = NewAppError(TypeGit, "Opened a pull request instead of pushing directly", nil)

ErrCreateBranch = NewAppError(TypeGit, "Failed to create branch", nil).
WithSuggestion("Make sure the branch name is valid and doesn't already exist locally")

ErrSwitchBranch = NewAppError(TypeGit, "Failed to switch branch", nil).
WithSuggestion("Make sure the branch exists: git branch -a")

ErrPushBranch = NewAppError(TypeGit, "Failed to push branch", nil).
WithSuggestion("Check your remote connection: git remote -v")

ErrFetchTags = NewAppError(TypeGit, "Failed to fetch tags from remote", nil).
WithSuggestion("Check your network connection and remote access")

Expand Down
22 changes: 22 additions & 0 deletions internal/errors/errors_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,28 @@ func TestAppError_ChainedContext(t *testing.T) {
}
}

func TestAppError_Is(t *testing.T) {
t.Run("errors.Is matches through WithError/WithContext despite returning new instances", func(t *testing.T) {
wrapped := ErrPushRejectedByRuleset.WithError(errors.New("exit status 1")).WithContext("stderr", "GH013")

if !errors.Is(wrapped, ErrPushRejectedByRuleset) {
t.Error("expected errors.Is to match the sentinel by Type+Message despite pointer inequality")
}
})

t.Run("different sentinel AppErrors of the same Type do not match", func(t *testing.T) {
if errors.Is(ErrPush, ErrPushRejectedByRuleset) {
t.Error("ErrPush and ErrPushRejectedByRuleset share TypeGit but have different Messages — must not match")
}
})

t.Run("a plain non-AppError never matches", func(t *testing.T) {
if errors.Is(errors.New("boom"), ErrPushRejectedByRuleset) {
t.Error("a plain error must never match an AppError sentinel")
}
})
}

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))))
Expand Down
109 changes: 107 additions & 2 deletions internal/git/git_service.go
Original file line number Diff line number Diff line change
Expand Up @@ -617,22 +617,127 @@ func (s *GitService) CreateTag(ctx context.Context, version, message string) err
}

func (s *GitService) PushTag(ctx context.Context, version string) error {
log := logger.FromContext(ctx)

cmd := exec.CommandContext(ctx, "git", "push", "origin", version)
var stderr strings.Builder
cmd.Stderr = &stderr

if err := cmd.Run(); err != nil {
return errors.ErrPushTag.WithError(err).WithContext("version", version)
stderrStr := strings.TrimSpace(stderr.String())
log.Error("git push tag failed", "error", err, "version", version, "stderr", stderrStr)

if isRulesetRejection(stderrStr) {
return errors.ErrPushRejectedByRuleset.WithError(err).WithContext("version", version).WithContext("stderr", stderrStr)
}
return errors.ErrPushTag.WithError(err).WithContext("version", version).WithContext("stderr", stderrStr)
}
return nil
}

// Push pushes commits to the remote repository
func (s *GitService) Push(ctx context.Context) error {
log := logger.FromContext(ctx)

cmd := exec.CommandContext(ctx, "git", "push")
var stderr strings.Builder
cmd.Stderr = &stderr

if err := cmd.Run(); err != nil {
stderrStr := strings.TrimSpace(stderr.String())
log.Error("git push failed", "error", err, "stderr", stderrStr)

if isRulesetRejection(stderrStr) {
return errors.ErrPushRejectedByRuleset.WithError(err).WithContext("stderr", stderrStr)
}
return errors.ErrPush.WithError(err).WithContext("stderr", stderrStr)
}
return nil
}

// CreateAndSwitchBranch creates a new local branch at the current HEAD and
// switches to it. Uses -B (not -b) so it's safe to call again with the same
// name (e.g. a retried release) — it just resets the branch to the current
// HEAD instead of failing because it already exists.
func (s *GitService) CreateAndSwitchBranch(ctx context.Context, branchName string) error {
log := logger.FromContext(ctx)

cmd := exec.CommandContext(ctx, "git", "checkout", "-B", branchName)
var stderr strings.Builder
cmd.Stderr = &stderr

if err := cmd.Run(); err != nil {
return errors.ErrPush.WithError(err)
stderrStr := strings.TrimSpace(stderr.String())
log.Error("git checkout -B failed", "error", err, "branch", branchName, "stderr", stderrStr)
return errors.ErrCreateBranch.WithError(err).WithContext("branch", branchName).WithContext("stderr", stderrStr)
}
return nil
}

// SwitchBranch checks out an existing local branch.
func (s *GitService) SwitchBranch(ctx context.Context, branchName string) error {
log := logger.FromContext(ctx)

cmd := exec.CommandContext(ctx, "git", "checkout", branchName)
var stderr strings.Builder
cmd.Stderr = &stderr

if err := cmd.Run(); err != nil {
stderrStr := strings.TrimSpace(stderr.String())
log.Error("git checkout failed", "error", err, "branch", branchName, "stderr", stderrStr)
return errors.ErrSwitchBranch.WithError(err).WithContext("branch", branchName).WithContext("stderr", stderrStr)
}
return nil
}

// PushBranch pushes a branch to origin, setting up tracking. Like
// Push/PushTag, it detects and flags a GitHub ruleset rejection so callers
// can tell "the branch itself is also protected" apart from any other
// push failure.
func (s *GitService) PushBranch(ctx context.Context, branchName string) error {
log := logger.FromContext(ctx)

cmd := exec.CommandContext(ctx, "git", "push", "-u", "origin", branchName)
var stderr strings.Builder
cmd.Stderr = &stderr

if err := cmd.Run(); err != nil {
stderrStr := strings.TrimSpace(stderr.String())
log.Error("git push branch failed", "error", err, "branch", branchName, "stderr", stderrStr)

if isRulesetRejection(stderrStr) {
return errors.ErrPushRejectedByRuleset.WithError(err).WithContext("branch", branchName).WithContext("stderr", stderrStr)
}
return errors.ErrPushBranch.WithError(err).WithContext("branch", branchName).WithContext("stderr", stderrStr)
}
return nil
}

// isRulesetRejection reports whether git's push failure output indicates the
// push was rejected by a GitHub ruleset or legacy branch/tag protection rule
// (as opposed to a network/auth/diverged-history failure). GH013 is GitHub's
// error code for the newer Rulesets feature; GH006 is the legacy branch
// protection equivalent — both include a human-readable reason afterward,
// which we surface via the raw stderr already attached to the AppError.
func isRulesetRejection(stderr string) bool {
markers := []string{
"GH013",
"GH006",
"protected branch",
"protected tag",
"protected ref",
"repository rule violations",
"push declined due to repository rule violations",
}
lower := strings.ToLower(stderr)
for _, m := range markers {
if strings.Contains(lower, strings.ToLower(m)) {
return true
}
}
return false
}

func (s *GitService) GetCommitCount(ctx context.Context) (int, error) {
cmd := exec.CommandContext(ctx, "git", "rev-list", "--count", "HEAD")
output, err := cmd.Output()
Expand Down
Loading
Loading