Skip to content

Commit 613f4cb

Browse files
authored
Return ExitError type (#196)
Shove the original command error in the error tree. This allows checking for the specific exit code by code like this: var exitError *exec.ExitError if errors.As(err, &exitError) { Added command.FormattedError type to work around issue of not returning the original exec.ExitError type. The Unwrap() method returns the original error, so the above example will now work.
1 parent 9606561 commit 613f4cb

File tree

5 files changed

+178
-5
lines changed

5 files changed

+178
-5
lines changed

command/command.go

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -168,11 +168,14 @@ func printableCommandArgs(isQuoteFirst bool, fullCommandArgs []string) string {
168168
func (c command) wrapError(err error) error {
169169
var exitErr *exec.ExitError
170170
if errors.As(err, &exitErr) {
171-
if c.errorCollector != nil && len(c.errorCollector.errorLines) > 0 {
172-
return fmt.Errorf("command failed with exit status %d (%s): %w", exitErr.ExitCode(), c.PrintableCommandArgs(), errors.New(strings.Join(c.errorCollector.errorLines, "\n")))
171+
errorLines := []string{}
172+
if c.errorCollector != nil {
173+
errorLines = c.errorCollector.errorLines
173174
}
174-
return fmt.Errorf("command failed with exit status %d (%s): %w", exitErr.ExitCode(), c.PrintableCommandArgs(), errors.New("check the command's output for details"))
175+
176+
return NewExitStatusError(c.PrintableCommandArgs(), exitErr, errorLines)
175177
}
178+
176179
return fmt.Errorf("executing command failed (%s): %w", c.PrintableCommandArgs(), err)
177180
}
178181

command/command_test.go

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package command
22

33
import (
44
"bytes"
5+
"errors"
56
"fmt"
67
"os/exec"
78
"strings"
@@ -65,7 +66,7 @@ Error: fourth error`,
6566
gotErrMsg = err.Error()
6667
}
6768
if gotErrMsg != tt.wantErr {
68-
t.Errorf("command.Run() error = %v, wantErr %v", gotErrMsg, tt.wantErr)
69+
t.Errorf("command.Run() error = \n%v\n, wantErr \n%v\n", gotErrMsg, tt.wantErr)
6970
return
7071
}
7172
})
@@ -123,6 +124,18 @@ func TestRunCmdAndReturnExitCode(t *testing.T) {
123124
t.Errorf("command.RunAndReturnExitCode() error = %v, wantErr %v", err, tt.wantErr)
124125
return
125126
}
127+
if tt.wantErr && tt.wantExitCode > 0 {
128+
var exitErr *exec.ExitError
129+
130+
if ok := errors.As(err, &exitErr); !ok {
131+
t.Errorf("command.RunAndReturnExitCode() did nor return ExitError type: %s", err)
132+
return
133+
}
134+
135+
if exitErr.ExitCode() != tt.wantExitCode {
136+
t.Errorf("command.RunAndReturnExitCode() exit code = %v, want %v", exitErr.ExitCode(), tt.wantExitCode)
137+
}
138+
}
126139
if gotExitCode != tt.wantExitCode {
127140
t.Errorf("command.RunAndReturnExitCode() = %v, want %v", gotExitCode, tt.wantExitCode)
128141
}

command/errors.go

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
package command
2+
3+
import (
4+
"errors"
5+
"fmt"
6+
"os/exec"
7+
"strings"
8+
)
9+
10+
// ExitStatusError ...
11+
type ExitStatusError struct {
12+
readableReason error
13+
originalExitErr error
14+
}
15+
16+
// NewExitStatusError ...
17+
func NewExitStatusError(printableCmdArgs string, exitErr *exec.ExitError, errorLines []string) error {
18+
reasonMsg := fmt.Sprintf("command failed with exit status %d (%s)", exitErr.ExitCode(), printableCmdArgs)
19+
if len(errorLines) == 0 {
20+
return &ExitStatusError{
21+
readableReason: fmt.Errorf("%s: %w", reasonMsg, errors.New("check the command's output for details")),
22+
originalExitErr: exitErr,
23+
}
24+
}
25+
26+
return &ExitStatusError{
27+
readableReason: fmt.Errorf("%s: %w", reasonMsg, errors.New(strings.Join(errorLines, "\n"))),
28+
originalExitErr: exitErr,
29+
}
30+
}
31+
32+
// Error returns the formatted error message. Does not include the original error message (`exit status 1`).
33+
func (e *ExitStatusError) Error() string {
34+
return e.readableReason.Error()
35+
}
36+
37+
// Unwrap is needed for errors.Is and errors.As to work correctly.
38+
func (e *ExitStatusError) Unwrap() error {
39+
return e.originalExitErr
40+
}
41+
42+
// Reason returns the user-friendly error, to be used by errorutil.ErrorFormatter.
43+
func (e *ExitStatusError) Reason() error {
44+
return e.readableReason
45+
}

errorutil/formatted_error.go

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@ package errorutil
33
import (
44
"errors"
55
"strings"
6+
7+
"github.com/bitrise-io/go-utils/v2/command"
68
)
79

810
// FormattedError ...
@@ -13,8 +15,12 @@ func FormattedError(err error) string {
1315
for {
1416
i++
1517

16-
reason := err.Error()
18+
// Use the user-friendly error message, ignore the original exec.ExitError.
19+
if commandExitStatusError, ok := err.(*command.ExitStatusError); ok {
20+
err = commandExitStatusError.Reason()
21+
}
1722

23+
reason := err.Error()
1824
if err = errors.Unwrap(err); err == nil {
1925
formatted = appendError(formatted, reason, i, true)
2026
return formatted

errorutil/formatted_error_test.go

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,12 @@ package errorutil
33
import (
44
"errors"
55
"fmt"
6+
"strings"
67
"testing"
8+
9+
"github.com/bitrise-io/go-utils/v2/command"
10+
"github.com/bitrise-io/go-utils/v2/env"
11+
"github.com/stretchr/testify/require"
712
)
813

914
func TestFormattedError(t *testing.T) {
@@ -60,3 +65,104 @@ func TestFormattedError(t *testing.T) {
6065
})
6166
}
6267
}
68+
69+
func TestFormattedErrorWithCommand(t *testing.T) {
70+
commandFactory := command.NewFactory(env.NewRepository())
71+
72+
tests := []struct {
73+
name string
74+
cmdFn func() error
75+
wantErr string
76+
wantMsg string
77+
}{
78+
{
79+
name: "command exit status error",
80+
cmdFn: func() error {
81+
cmd := commandFactory.Create("bash", []string{"../command/testdata/exit_with_message.sh"}, nil)
82+
return cmd.Run()
83+
},
84+
wantErr: `command failed with exit status 1 (bash "../command/testdata/exit_with_message.sh"): check the command's output for details`,
85+
wantMsg: `command failed with exit status 1 (bash "../command/testdata/exit_with_message.sh"):
86+
check the command's output for details`,
87+
},
88+
{
89+
name: "command execution failed, wrapped",
90+
cmdFn: func() error {
91+
cmd := commandFactory.Create("__notfoundinpath", []string{}, nil)
92+
if err := cmd.Run(); err != nil {
93+
return fmt.Errorf("wrapped: %w", err)
94+
}
95+
return nil
96+
},
97+
wantErr: `wrapped: executing command failed (__notfoundinpath): exec: "__notfoundinpath": executable file not found in $PATH`,
98+
wantMsg: `wrapped:
99+
executing command failed (__notfoundinpath):
100+
exec: "__notfoundinpath":
101+
executable file not found in $PATH`,
102+
},
103+
{
104+
name: "command error, wrapped",
105+
cmdFn: func() error {
106+
cmd := commandFactory.Create("bash", []string{"../command/testdata/exit_with_message.sh"}, nil)
107+
if err := cmd.Run(); err != nil {
108+
return fmt.Errorf("wrapped: %w", err)
109+
}
110+
return nil
111+
},
112+
wantErr: `wrapped: command failed with exit status 1 (bash "../command/testdata/exit_with_message.sh"): check the command's output for details`,
113+
wantMsg: `wrapped:
114+
command failed with exit status 1 (bash "../command/testdata/exit_with_message.sh"):
115+
check the command's output for details`,
116+
},
117+
{
118+
name: "command with error finder",
119+
cmdFn: func() error {
120+
errorFinder := func(out string) []string {
121+
var errors []string
122+
for _, line := range strings.Split(out, "\n") {
123+
if strings.Contains(line, "Error:") {
124+
errors = append(errors, line)
125+
}
126+
}
127+
return errors
128+
}
129+
130+
cmd := commandFactory.Create("bash", []string{"../command/testdata/exit_with_message.sh"}, &command.Opts{
131+
ErrorFinder: errorFinder,
132+
})
133+
134+
err := cmd.Run()
135+
return err
136+
},
137+
wantErr: `command failed with exit status 1 (bash "../command/testdata/exit_with_message.sh"): Error: first error
138+
Error: second error
139+
Error: third error
140+
Error: fourth error`,
141+
wantMsg: `command failed with exit status 1 (bash "../command/testdata/exit_with_message.sh"):
142+
Error: first error
143+
Error: second error
144+
Error: third error
145+
Error: fourth error`,
146+
},
147+
}
148+
for _, tt := range tests {
149+
t.Run(tt.name, func(t *testing.T) {
150+
err := tt.cmdFn()
151+
152+
var gotErrMsg string
153+
if err != nil {
154+
gotErrMsg = err.Error()
155+
}
156+
if gotErrMsg != tt.wantErr {
157+
t.Errorf("command.Run() error = \n%v\n, wantErr \n%v\n", gotErrMsg, tt.wantErr)
158+
return
159+
}
160+
161+
gotFormattedMsg := FormattedError(err)
162+
require.Equal(t, tt.wantMsg, gotFormattedMsg, "FormattedError() error = \n%v\n, wantErr \n%v\n", gotFormattedMsg, tt.wantErr)
163+
if gotFormattedMsg != tt.wantMsg {
164+
t.Errorf("FormattedError() error = \n%v\n, wantErr \n%v\n", gotFormattedMsg, tt.wantErr)
165+
}
166+
})
167+
}
168+
}

0 commit comments

Comments
 (0)