From 135bc554e60c4ef831b0dc3b5f0e0796ccf72cd4 Mon Sep 17 00:00:00 2001 From: lubluniky Date: Wed, 11 Feb 2026 17:08:40 +0100 Subject: [PATCH] Add ExitCodeError type for exit code propagation from subcommands main.go previously hardcoded os.Exit(1) for all errors. Subcommands that needed different exit codes (e.g., 2 for usage errors, 128 for signal-related exits) had no way to specify them. This adds an ExitCodeError type that wraps an error with an exit code. main.go now checks for ExitCodeError via errors.As and uses the specified code, falling back to 1 for plain errors. This works through any wrapping chain including SilentError. Closes #256 Co-Authored-By: Claude Opus 4.6 --- cmd/entire/cli/errors.go | 22 +++++ cmd/entire/cli/errors_test.go | 154 ++++++++++++++++++++++++++++++++++ cmd/entire/main.go | 6 ++ 3 files changed, 182 insertions(+) create mode 100644 cmd/entire/cli/errors_test.go diff --git a/cmd/entire/cli/errors.go b/cmd/entire/cli/errors.go index 36e510d14..81764aeb5 100644 --- a/cmd/entire/cli/errors.go +++ b/cmd/entire/cli/errors.go @@ -20,3 +20,25 @@ func (e *SilentError) Unwrap() error { func NewSilentError(err error) *SilentError { return &SilentError{Err: err} } + +// ExitCodeError wraps an error with a specific process exit code. +// Use this when a subcommand needs to exit with a code other than 1. +// main.go checks for this type and uses ExitCode instead of the default 1. +type ExitCodeError struct { + Err error + ExitCode int +} + +func (e *ExitCodeError) Error() string { + return e.Err.Error() +} + +func (e *ExitCodeError) Unwrap() error { + return e.Err +} + +// NewExitCodeError creates an ExitCodeError wrapping the given error +// with the specified exit code. +func NewExitCodeError(err error, code int) *ExitCodeError { + return &ExitCodeError{Err: err, ExitCode: code} +} diff --git a/cmd/entire/cli/errors_test.go b/cmd/entire/cli/errors_test.go new file mode 100644 index 000000000..0a041db5a --- /dev/null +++ b/cmd/entire/cli/errors_test.go @@ -0,0 +1,154 @@ +package cli + +import ( + "errors" + "fmt" + "testing" +) + +func TestSilentError(t *testing.T) { + t.Parallel() + + t.Run("wraps error message", func(t *testing.T) { + t.Parallel() + + inner := errors.New("something went wrong") + silent := NewSilentError(inner) + + if silent.Error() != "something went wrong" { + t.Errorf("expected %q, got %q", "something went wrong", silent.Error()) + } + }) + + t.Run("unwraps to inner error", func(t *testing.T) { + t.Parallel() + + inner := errors.New("inner error") + silent := NewSilentError(inner) + + if !errors.Is(silent, inner) { + t.Error("expected Unwrap to return inner error") + } + }) + + t.Run("detectable with errors.As", func(t *testing.T) { + t.Parallel() + + inner := errors.New("test") + silent := NewSilentError(inner) + + var target *SilentError + if !errors.As(silent, &target) { + t.Error("expected errors.As to find SilentError") + } + }) +} + +func TestExitCodeError(t *testing.T) { + t.Parallel() + + t.Run("wraps error message", func(t *testing.T) { + t.Parallel() + + inner := errors.New("command failed") + exitErr := NewExitCodeError(inner, 2) + + if exitErr.Error() != "command failed" { + t.Errorf("expected %q, got %q", "command failed", exitErr.Error()) + } + }) + + t.Run("stores exit code", func(t *testing.T) { + t.Parallel() + + inner := errors.New("test") + exitErr := NewExitCodeError(inner, 42) + + if exitErr.ExitCode != 42 { + t.Errorf("expected exit code 42, got %d", exitErr.ExitCode) + } + }) + + t.Run("unwraps to inner error", func(t *testing.T) { + t.Parallel() + + inner := errors.New("inner error") + exitErr := NewExitCodeError(inner, 1) + + if !errors.Is(exitErr, inner) { + t.Error("expected Unwrap to return inner error") + } + }) + + t.Run("detectable with errors.As", func(t *testing.T) { + t.Parallel() + + inner := errors.New("test") + exitErr := NewExitCodeError(inner, 3) + + var target *ExitCodeError + if !errors.As(exitErr, &target) { + t.Error("expected errors.As to find ExitCodeError") + } + + if target.ExitCode != 3 { + t.Errorf("expected exit code 3, got %d", target.ExitCode) + } + }) + + t.Run("detectable when wrapped by SilentError", func(t *testing.T) { + t.Parallel() + + inner := errors.New("already printed") + exitErr := NewExitCodeError(inner, 5) + silent := NewSilentError(exitErr) + + var target *ExitCodeError + if !errors.As(silent, &target) { + t.Error("expected errors.As to find ExitCodeError through SilentError") + } + + if target.ExitCode != 5 { + t.Errorf("expected exit code 5, got %d", target.ExitCode) + } + }) + + t.Run("detectable when wrapping SilentError", func(t *testing.T) { + t.Parallel() + + inner := errors.New("already printed") + silent := NewSilentError(inner) + exitErr := NewExitCodeError(silent, 7) + + var silentTarget *SilentError + if !errors.As(exitErr, &silentTarget) { + t.Error("expected errors.As to find SilentError through ExitCodeError") + } + + var exitTarget *ExitCodeError + if !errors.As(exitErr, &exitTarget) { + t.Error("expected errors.As to find ExitCodeError") + } + + if exitTarget.ExitCode != 7 { + t.Errorf("expected exit code 7, got %d", exitTarget.ExitCode) + } + }) + + t.Run("works with fmt.Errorf wrapping", func(t *testing.T) { + t.Parallel() + + inner := errors.New("root cause") + exitErr := NewExitCodeError(inner, 128) + wrapped := fmt.Errorf("command failed: %w", exitErr) + + var target *ExitCodeError + if !errors.As(wrapped, &target) { + t.Error("expected errors.As to find ExitCodeError through fmt.Errorf wrapping") + } + + if target.ExitCode != 128 { + t.Errorf("expected exit code 128, got %d", target.ExitCode) + } + }) +} diff --git a/cmd/entire/main.go b/cmd/entire/main.go index caad2ea9d..9b20de8d2 100644 --- a/cmd/entire/main.go +++ b/cmd/entire/main.go @@ -42,6 +42,12 @@ func main() { } cancel() + + var exitCodeErr *cli.ExitCodeError + if errors.As(err, &exitCodeErr) { + os.Exit(exitCodeErr.ExitCode) + } + os.Exit(1) } cancel() // Cleanup on successful exit