diff --git a/cmd/cmd_test.go b/cmd/cmd_test.go index b3a764e..1d829f5 100644 --- a/cmd/cmd_test.go +++ b/cmd/cmd_test.go @@ -9,6 +9,7 @@ import ( "time" "github.com/samzong/gmc/internal/config" + "github.com/samzong/gmc/internal/exitcode" "github.com/samzong/gmc/internal/git" "github.com/samzong/gmc/internal/llm" "github.com/samzong/gmc/internal/workflow" @@ -60,6 +61,29 @@ func TestHandleErrors(t *testing.T) { assert.ErrorIs(t, errWithoutHint, workflow.ErrNoChanges) }) + t.Run("not git repo returns exit code 11", func(t *testing.T) { + err := handleErrors(fmt.Errorf("failed: %w", git.ErrNotGitRepo), false) + var exitErr *exitcode.Error + assert.ErrorAs(t, err, &exitErr) + assert.Equal(t, exitcode.NotGitRepo, exitErr.Code) + assert.ErrorIs(t, err, git.ErrNotGitRepo) + }) + + t.Run("LLM error returns exit code 12", func(t *testing.T) { + err := handleErrors(fmt.Errorf("generation failed: %w", llm.ErrLLM), false) + var exitErr *exitcode.Error + assert.ErrorAs(t, err, &exitErr) + assert.Equal(t, exitcode.LLMError, exitErr.Code) + assert.ErrorIs(t, err, llm.ErrLLM) + }) + + t.Run("no staged changes returns exit code 10", func(t *testing.T) { + err := handleErrors(workflow.ErrNoChanges, false) + var exitErr *exitcode.Error + assert.ErrorAs(t, err, &exitErr) + assert.Equal(t, exitcode.NoStagedChanges, exitErr.Code) + }) + t.Run("propagates generic error", func(t *testing.T) { expectedErr := errors.New("boom") err := handleErrors(expectedErr, false) diff --git a/cmd/root.go b/cmd/root.go index 751d521..b69d929 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -10,6 +10,7 @@ import ( "github.com/mattn/go-isatty" "github.com/samzong/gmc/internal/config" + "github.com/samzong/gmc/internal/exitcode" "github.com/samzong/gmc/internal/formatter" "github.com/samzong/gmc/internal/git" "github.com/samzong/gmc/internal/llm" @@ -99,12 +100,26 @@ func handleErrors(err error, addAllFlag bool) error { if !addAllFlag { msg += "\nHint: You can use -a or --all to automatically add all changes to the staging area." } - return userFacingError{msg: msg, err: err} + return exitcode.New(exitcode.NoStagedChanges, msg, err) + } + + if coded := classifyError(err); coded != nil { + return coded } return userFacingError{msg: fmt.Sprintf("gmc: %v", err), err: err} } +func classifyError(err error) *exitcode.Error { + if errors.Is(err, git.ErrNotGitRepo) { + return exitcode.New(exitcode.NotGitRepo, err.Error(), err) + } + if errors.Is(err, llm.ErrLLM) { + return exitcode.New(exitcode.LLMError, err.Error(), err) + } + return nil +} + type userFacingError struct { msg string err error diff --git a/cmd/tag.go b/cmd/tag.go index 4eb3a1a..0b24d57 100644 --- a/cmd/tag.go +++ b/cmd/tag.go @@ -58,7 +58,7 @@ func runTagCommand() error { lastTag, commits, err := collectTagContext(gitClient) if err != nil { - return err + return wrapTagError(err) } if len(commits) == 0 { printNoCommitsSinceLastTag(lastTag) @@ -67,14 +67,14 @@ func runTagCommand() error { baseVersion, displayTag, err := resolveBaseVersion(lastTag) if err != nil { - return err + return wrapTagError(err) } printCommitSummary(displayTag, commits) finalVersion, finalReason, source, err := pickTagSuggestion(baseVersion, commits, llmClient) if err != nil { - return err + return wrapTagError(err) } fmt.Fprintf(outWriter(), "Suggested version (%s): %s\n", source, finalVersion.String()) if strings.TrimSpace(finalReason) != "" { @@ -89,7 +89,7 @@ func runTagCommand() error { confirmed, err := confirmTagCreation(finalVersion.String()) if err != nil { - return fmt.Errorf("failed to read confirmation: %w", err) + return wrapTagError(fmt.Errorf("failed to read confirmation: %w", err)) } if !confirmed { @@ -103,7 +103,7 @@ func runTagCommand() error { } if err := gitClient.CreateAnnotatedTag(finalVersion.String(), tagMessage); err != nil { - return fmt.Errorf("failed to create tag: %w", err) + return wrapTagError(fmt.Errorf("failed to create tag: %w", err)) } fmt.Fprintf(outWriter(), "Tag %s created successfully.\n", finalVersion.String()) @@ -276,3 +276,10 @@ func confirmTagCreation(tag string) (bool, error) { answer := strings.TrimSpace(strings.ToLower(input)) return answer == "y" || answer == "yes", nil } + +func wrapTagError(err error) error { + if coded := classifyError(err); coded != nil { + return coded + } + return err +} diff --git a/internal/exitcode/exitcode.go b/internal/exitcode/exitcode.go new file mode 100644 index 0000000..b95b58f --- /dev/null +++ b/internal/exitcode/exitcode.go @@ -0,0 +1,23 @@ +package exitcode + +const ( + Success = 0 + General = 1 + Usage = 2 + NoStagedChanges = 10 + NotGitRepo = 11 + LLMError = 12 +) + +type Error struct { + Code int + Message string + Err error +} + +func (e *Error) Error() string { return e.Message } +func (e *Error) Unwrap() error { return e.Err } + +func New(code int, msg string, err error) *Error { + return &Error{Code: code, Message: msg, Err: err} +} diff --git a/internal/git/git.go b/internal/git/git.go index f2c8b5f..7a1795d 100644 --- a/internal/git/git.go +++ b/internal/git/git.go @@ -52,9 +52,11 @@ func (c *Client) IsGitRepository() bool { return err == nil } +var ErrNotGitRepo = errors.New("not a git repository") + func (c *Client) CheckGitRepository() error { if !c.IsGitRepository() { - return errors.New("not in a git repository. Please run this command in a git repository directory") + return fmt.Errorf("%w: please run this command inside a git working tree", ErrNotGitRepo) } return nil } diff --git a/internal/git/git_test.go b/internal/git/git_test.go index 0e4ac16..920b9c0 100644 --- a/internal/git/git_test.go +++ b/internal/git/git_test.go @@ -428,20 +428,17 @@ func TestOutsideGitRepo(t *testing.T) { t.Run("CheckGitRepository_OutsideRepo", func(t *testing.T) { err := client.CheckGitRepository() - assert.Error(t, err) - assert.Contains(t, err.Error(), "not in a git repository") + assert.ErrorIs(t, err, ErrNotGitRepo) }) t.Run("GetDiff_OutsideRepo", func(t *testing.T) { _, err := client.GetDiff() - assert.Error(t, err) - assert.Contains(t, err.Error(), "not in a git repository") + assert.ErrorIs(t, err, ErrNotGitRepo) }) t.Run("GetStagedDiff_OutsideRepo", func(t *testing.T) { _, err := client.GetStagedDiff() - assert.Error(t, err) - assert.Contains(t, err.Error(), "not in a git repository") + assert.ErrorIs(t, err, ErrNotGitRepo) }) t.Run("AddAll_OutsideRepo", func(t *testing.T) { diff --git a/internal/llm/llm.go b/internal/llm/llm.go index 6915cc1..3421181 100644 --- a/internal/llm/llm.go +++ b/internal/llm/llm.go @@ -12,6 +12,8 @@ import ( "github.com/sashabaranov/go-openai" ) +var ErrLLM = errors.New("LLM error") + type Options struct { Timeout time.Duration } @@ -99,11 +101,11 @@ func (c *Client) GenerateCommitMessage(prompt string, model string) (string, err ) if err != nil { - return "", fmt.Errorf("failed to call LLM: %w", err) + return "", fmt.Errorf("failed to call LLM: %w (%w)", err, ErrLLM) } if len(resp.Choices) == 0 { - return "", errors.New("LLM returned empty response") + return "", fmt.Errorf("LLM returned empty response: %w", ErrLLM) } return strings.TrimSpace(resp.Choices[0].Message.Content), nil @@ -143,11 +145,11 @@ func (c *Client) SuggestVersion(baseVersion string, commits []string, model stri ) if err != nil { - return "", "", fmt.Errorf("failed to call LLM: %w", err) + return "", "", fmt.Errorf("failed to call LLM: %w (%w)", err, ErrLLM) } if len(resp.Choices) == 0 { - return "", "", errors.New("LLM returned empty response") + return "", "", fmt.Errorf("LLM returned empty response: %w", ErrLLM) } version, reason, err := parseVersionSuggestion(resp.Choices[0].Message.Content) diff --git a/main.go b/main.go index 0d6adcf..8cfd493 100644 --- a/main.go +++ b/main.go @@ -1,13 +1,19 @@ package main import ( + "errors" "os" "github.com/samzong/gmc/cmd" + "github.com/samzong/gmc/internal/exitcode" ) func main() { if err := cmd.Execute(); err != nil { - os.Exit(1) + var exitErr *exitcode.Error + if errors.As(err, &exitErr) { + os.Exit(exitErr.Code) + } + os.Exit(exitcode.General) } }