From f9c77acd9695f28ef45de0974fcabcd8bf0c71ed Mon Sep 17 00:00:00 2001 From: Pete Davison Date: Sat, 15 Apr 2023 21:22:25 +0100 Subject: [PATCH] feat: custom error codes (#1114) --- CHANGELOG.md | 6 ++- cmd/task/task.go | 65 +++++++++++++----------- docs/docs/api_reference.md | 29 +++++++++++ errors.go | 78 ----------------------------- errors/errors.go | 40 +++++++++++++++ errors/errors_task.go | 100 +++++++++++++++++++++++++++++++++++++ errors/errors_taskfile.go | 51 +++++++++++++++++++ init.go | 3 +- task.go | 23 +++++---- task_test.go | 7 +-- taskfile/read/taskfile.go | 8 +-- watch.go | 5 +- 12 files changed, 286 insertions(+), 129 deletions(-) delete mode 100644 errors.go create mode 100644 errors/errors.go create mode 100644 errors/errors_task.go create mode 100644 errors/errors_taskfile.go diff --git a/CHANGELOG.md b/CHANGELOG.md index de2d5a4a..10fa9c1d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,10 +9,12 @@ #1107 by @danquah). - Add `.hg` (Mercurial) to the list of ignored directories when using `--watch` (#1098 by @misery). -- More improvements to the release tool (#1096 by @pd93) +- More improvements to the release tool (#1096 by @pd93). - Enforce [gofumpt](https://github.com/mvdan/gofumpt) linter (#1099 by @pd93) - Add `--sort` flag for use with `--list` and `--list-all` (#946, #1105 by - @pd93) + @pd93). +- Task now has [custom exit codes](https://taskfile.dev/api/#exit-codes) + depending on the error (#1114 by @pd93). ## v3.23.0 - 2023-03-26 diff --git a/cmd/task/task.go b/cmd/task/task.go index b2183268..335a598b 100644 --- a/cmd/task/task.go +++ b/cmd/task/task.go @@ -14,6 +14,7 @@ import ( "github.com/go-task/task/v3" "github.com/go-task/task/v3/args" + "github.com/go-task/task/v3/errors" "github.com/go-task/task/v3/internal/logger" "github.com/go-task/task/v3/internal/sort" ver "github.com/go-task/task/v3/internal/version" @@ -43,6 +44,17 @@ Options: ` func main() { + if err := run(); err != nil { + if err, ok := err.(errors.TaskError); ok { + log.Print(err.Error()) + os.Exit(err.Code()) + } + os.Exit(errors.CodeUnknown) + } + os.Exit(errors.CodeOk) +} + +func run() error { log.SetFlags(0) log.SetOutput(os.Stderr) @@ -107,12 +119,12 @@ func main() { if versionFlag { fmt.Printf("Task version: %s\n", ver.GetVersion()) - return + return nil } if helpFlag { pflag.Usage() - return + return nil } if init { @@ -123,25 +135,23 @@ func main() { if err := task.InitTaskfile(os.Stdout, wd); err != nil { log.Fatal(err) } - return + return nil } if global && dir != "" { log.Fatal("task: You can't set both --global and --dir") - return + return nil } if global { home, err := os.UserHomeDir() if err != nil { - log.Fatal("task: Failed to get user home directory: %w", err) - return + return fmt.Errorf("task: Failed to get user home directory: %w", err) } dir = home } if dir != "" && entrypoint != "" { - log.Fatal("task: You can't set both --dir and --taskfile") - return + return errors.New("task: You can't set both --dir and --taskfile") } if entrypoint != "" { dir = filepath.Dir(entrypoint) @@ -150,16 +160,13 @@ func main() { if output.Name != "group" { if output.Group.Begin != "" { - log.Fatal("task: You can't set --output-group-begin without --output=group") - return + return errors.New("task: You can't set --output-group-begin without --output=group") } if output.Group.End != "" { - log.Fatal("task: You can't set --output-group-end without --output=group") - return + return errors.New("task: You can't set --output-group-end without --output=group") } if output.Group.ErrorOnly { - log.Fatal("task: You can't set --output-group-error-only without --output=group") - return + return errors.New("task: You can't set --output-group-error-only without --output=group") } } @@ -195,23 +202,27 @@ func main() { listOptions := task.NewListOptions(list, listAll, listJson) if err := listOptions.Validate(); err != nil { - log.Fatal(err) + return err } if (listOptions.ShouldListTasks()) && silent { e.ListTaskNames(listAll) - return + return nil } if err := e.Setup(); err != nil { - log.Fatal(err) + return err } if listOptions.ShouldListTasks() { - if foundTasks, err := e.ListTasks(listOptions); !foundTasks || err != nil { - os.Exit(1) + foundTasks, err := e.ListTasks(listOptions) + if err != nil { + return err } - return + if !foundTasks { + os.Exit(errors.CodeUnknown) + } + return nil } var ( @@ -221,7 +232,7 @@ func main() { tasksAndVars, cliArgs, err := getArgs() if err != nil { - log.Fatal(err) + return err } if e.Taskfile.Version.Compare(taskfile.V3) >= 0 { @@ -240,22 +251,20 @@ func main() { ctx := context.Background() if status { - if err := e.Status(ctx, calls...); err != nil { - log.Fatal(err) - } - return + return e.Status(ctx, calls...) } if err := e.Run(ctx, calls...); err != nil { e.Logger.Errf(logger.Red, "%v", err) if exitCode { - if err, ok := err.(*task.TaskRunError); ok { - os.Exit(err.ExitCode()) + if err, ok := err.(*errors.TaskRunError); ok { + os.Exit(err.TaskExitCode()) } } - os.Exit(1) + return err } + return nil } func getArgs() ([]string, string, error) { diff --git a/docs/docs/api_reference.md b/docs/docs/api_reference.md index 38c97269..1c1c1dea 100644 --- a/docs/docs/api_reference.md +++ b/docs/docs/api_reference.md @@ -51,6 +51,35 @@ If `--` is given, all remaning arguments will be assigned to a special | | `--version` | `bool` | `false` | Show Task version. | | `-w` | `--watch` | `bool` | `false` | Enables watch of the given task. | +## Exit Codes + +Task will sometimes exit with specific exit codes. These codes are split into three groups with the following ranges: + +- General errors (0-99) +- Taskfile errors (100-199) +- Task errors (200-299) + +A full list of the exit codes and their descriptions can be found below: + +| Code | Description | +| ---- | ------------------------------------------------------------ | +| 0 | Success | +| 1 | An unknown error occurred | +| 100 | No Taskfile was found | +| 101 | A Taskfile already exists when trying to initialize one | +| 102 | The Taskfile is invalid or cannot be parsed | +| 200 | The specified task could not be found | +| 201 | An error occurred while executing a command inside of a task | +| 202 | The user tried to invoke a task that is internal | +| 203 | There a multiple tasks with the same name or alias | +| 204 | A task was called too many times | + +These codes can also be found in the repository in [`errors/errors.go`](https://github.com/go-task/task/blob/master/errors/errors.go). + +:::info +When Task is run with the `-x`/`--exit-code` flag, the exit code of any failed commands will be passed through to the user instead. +::: + ## JSON Output When using the `--json` flag in combination with either the `--list` or diff --git a/errors.go b/errors.go deleted file mode 100644 index 610b3e85..00000000 --- a/errors.go +++ /dev/null @@ -1,78 +0,0 @@ -package task - -import ( - "errors" - "fmt" - "strings" - - "mvdan.cc/sh/v3/interp" -) - -// ErrTaskfileAlreadyExists is returned on creating a Taskfile if one already exists -var ErrTaskfileAlreadyExists = errors.New("task: A Taskfile already exists") - -type taskNotFoundError struct { - taskName string - didYouMean string -} - -func (err *taskNotFoundError) Error() string { - if err.didYouMean != "" { - return fmt.Sprintf( - `task: Task %q does not exist. Did you mean %q?`, - err.taskName, - err.didYouMean, - ) - } - - return fmt.Sprintf(`task: Task %q does not exist`, err.taskName) -} - -type multipleTasksWithAliasError struct { - aliasName string - taskNames []string -} - -func (err *multipleTasksWithAliasError) Error() string { - return fmt.Sprintf(`task: Multiple tasks (%s) with alias %q found`, strings.Join(err.taskNames, ", "), err.aliasName) -} - -type taskInternalError struct { - taskName string -} - -func (err *taskInternalError) Error() string { - return fmt.Sprintf(`task: Task "%s" is internal`, err.taskName) -} - -type TaskRunError struct { - taskName string - err error -} - -func (err *TaskRunError) Error() string { - return fmt.Sprintf(`task: Failed to run task %q: %v`, err.taskName, err.err) -} - -func (err *TaskRunError) ExitCode() int { - if c, ok := interp.IsExitStatus(err.err); ok { - return int(c) - } - - return 1 -} - -// MaximumTaskCallExceededError is returned when a task is called too -// many times. In this case you probably have a cyclic dependendy or -// infinite loop -type MaximumTaskCallExceededError struct { - task string -} - -func (e *MaximumTaskCallExceededError) Error() string { - return fmt.Sprintf( - `task: maximum task call exceeded (%d) for task %q: probably an cyclic dep or infinite loop`, - MaximumTaskCall, - e.task, - ) -} diff --git a/errors/errors.go b/errors/errors.go new file mode 100644 index 00000000..c777faa8 --- /dev/null +++ b/errors/errors.go @@ -0,0 +1,40 @@ +package errors + +import "errors" + +// General exit codes +const ( + CodeOk int = iota // Used when the program exits without errors + CodeUnknown // Used when no other exit code is appropriate +) + +// Taskfile related exit codes +const ( + CodeTaskfileNotFound int = iota + 100 + CodeTaskfileAlreadyExists + CodeTaskfileInvalid +) + +// Task related exit codes +const ( + CodeTaskNotFound int = iota + 200 + CodeTaskRunError + CodeTaskInternal + CodeTaskNameConflict + CodeTaskCalledTooManyTimes +) + +// TaskError extends the standard error interface with a Code method. This code will +// be used as the exit code of the program which allows the user to distinguish +// between different types of errors. +type TaskError interface { + error + Code() int +} + +// New returns an error that formats as the given text. Each call to New returns +// a distinct error value even if the text is identical. This wraps the standard +// errors.New function so that we don't need to alias that package. +func New(text string) error { + return errors.New(text) +} diff --git a/errors/errors_task.go b/errors/errors_task.go new file mode 100644 index 00000000..92109ceb --- /dev/null +++ b/errors/errors_task.go @@ -0,0 +1,100 @@ +package errors + +import ( + "fmt" + "strings" + + "mvdan.cc/sh/v3/interp" +) + +// TaskNotFoundError is returned when the specified task is not found in the +// Taskfile. +type TaskNotFoundError struct { + TaskName string + DidYouMean string +} + +func (err *TaskNotFoundError) Error() string { + if err.DidYouMean != "" { + return fmt.Sprintf( + `task: Task %q does not exist. Did you mean %q?`, + err.TaskName, + err.DidYouMean, + ) + } + + return fmt.Sprintf(`task: Task %q does not exist`, err.TaskName) +} + +func (err *TaskNotFoundError) Code() int { + return CodeTaskNotFound +} + +// TaskRunError is returned when a command in a task returns a non-zero exit +// code. +type TaskRunError struct { + TaskName string + Err error +} + +func (err *TaskRunError) Error() string { + return fmt.Sprintf(`task: Failed to run task %q: %v`, err.TaskName, err.Err) +} + +func (err *TaskRunError) Code() int { + return CodeTaskRunError +} + +func (err *TaskRunError) TaskExitCode() int { + if c, ok := interp.IsExitStatus(err.Err); ok { + return int(c) + } + return err.Code() +} + +// TaskInternalError when the user attempts to invoke a task that is internal. +type TaskInternalError struct { + TaskName string +} + +func (err *TaskInternalError) Error() string { + return fmt.Sprintf(`task: Task "%s" is internal`, err.TaskName) +} + +func (err *TaskInternalError) Code() int { + return CodeTaskInternal +} + +// TaskNameConflictError is returned when multiple tasks with the same name or +// alias are found. +type TaskNameConflictError struct { + AliasName string + TaskNames []string +} + +func (err *TaskNameConflictError) Error() string { + return fmt.Sprintf(`task: Multiple tasks (%s) with alias %q found`, strings.Join(err.TaskNames, ", "), err.AliasName) +} + +func (err *TaskNameConflictError) Code() int { + return CodeTaskNameConflict +} + +// TaskCalledTooManyTimesError is returned when the maximum task call limit is +// exceeded. This is to prevent infinite loops and cyclic dependencies. +type TaskCalledTooManyTimesError struct { + TaskName string + MaximumTaskCall int +} + +func (err *TaskCalledTooManyTimesError) Error() string { + return fmt.Sprintf( + `task: maximum task call exceeded (%d) for task %q: probably an cyclic dep or infinite loop`, + err.MaximumTaskCall, + err.TaskName, + ) +} + +func (err *TaskCalledTooManyTimesError) Code() int { + return CodeTaskCalledTooManyTimes +} diff --git a/errors/errors_taskfile.go b/errors/errors_taskfile.go new file mode 100644 index 00000000..18e66286 --- /dev/null +++ b/errors/errors_taskfile.go @@ -0,0 +1,51 @@ +package errors + +import ( + "fmt" +) + +// TaskfileNotFoundError is returned when no appropriate Taskfile is found when +// searching the filesystem. +type TaskfileNotFoundError struct { + Dir string + Walk bool +} + +func (err TaskfileNotFoundError) Error() string { + var walkText string + if err.Walk { + walkText = " (or any of the parent directories)" + } + return fmt.Sprintf(`task: No Taskfile found in "%s"%s. Use "task --init" to create a new one`, err.Dir, walkText) +} + +func (err TaskfileNotFoundError) Code() int { + return CodeTaskfileNotFound +} + +// TaskfileAlreadyExistsError is returned on creating a Taskfile if one already +// exists. +type TaskfileAlreadyExistsError struct{} + +func (err TaskfileAlreadyExistsError) Error() string { + return "task: A Taskfile already exists" +} + +func (err TaskfileAlreadyExistsError) Code() int { + return CodeTaskfileAlreadyExists +} + +// TaskfileInvalidError is returned when the Taskfile contains syntax errors or +// cannot be parsed for some reason. +type TaskfileInvalidError struct { + FilePath string + Err error +} + +func (err TaskfileInvalidError) Error() string { + return fmt.Sprintf("task: Failed to parse %s:\n%v", err.FilePath, err.Err) +} + +func (err TaskfileInvalidError) Code() int { + return CodeTaskfileInvalid +} diff --git a/init.go b/init.go index ab301ea5..79d04348 100644 --- a/init.go +++ b/init.go @@ -5,6 +5,7 @@ import ( "io" "os" + "github.com/go-task/task/v3/errors" "github.com/go-task/task/v3/internal/filepathext" ) @@ -29,7 +30,7 @@ func InitTaskfile(w io.Writer, dir string) error { f := filepathext.SmartJoin(dir, defaultTaskfileName) if _, err := os.Stat(f); err == nil { - return ErrTaskfileAlreadyExists + return errors.TaskfileAlreadyExistsError{} } if err := os.WriteFile(f, []byte(defaultTaskfile), 0o644); err != nil { diff --git a/task.go b/task.go index 98f6aca3..f1ebb561 100644 --- a/task.go +++ b/task.go @@ -10,6 +10,7 @@ import ( "sync/atomic" "time" + "github.com/go-task/task/v3/errors" "github.com/go-task/task/v3/internal/compiler" "github.com/go-task/task/v3/internal/env" "github.com/go-task/task/v3/internal/execext" @@ -77,7 +78,7 @@ func (e *Executor) Run(ctx context.Context, calls ...taskfile.Call) error { for _, call := range calls { task, err := e.GetTask(call) if err != nil { - if _, ok := err.(*taskNotFoundError); ok { + if _, ok := err.(*errors.TaskNotFoundError); ok { if _, err := e.ListTasks(ListOptions{ListOnlyTasksWithDescriptions: true}); err != nil { return err } @@ -86,12 +87,12 @@ func (e *Executor) Run(ctx context.Context, calls ...taskfile.Call) error { } if task.Internal { - if _, ok := err.(*taskNotFoundError); ok { + if _, ok := err.(*errors.TaskNotFoundError); ok { if _, err := e.ListTasks(ListOptions{ListOnlyTasksWithDescriptions: true}); err != nil { return err } } - return &taskInternalError{taskName: call.Task} + return &errors.TaskInternalError{TaskName: call.Task} } } @@ -132,7 +133,7 @@ func (e *Executor) RunTask(ctx context.Context, call taskfile.Call) error { return err } if !e.Watch && atomic.AddInt32(e.taskCallCount[t.Task], 1) >= MaximumTaskCall { - return &MaximumTaskCallExceededError{task: t.Task} + return &errors.TaskCalledTooManyTimesError{TaskName: t.Task} } release := e.acquireConcurrencyLimit() @@ -203,7 +204,7 @@ func (e *Executor) RunTask(ctx context.Context, call taskfile.Call) error { continue } - return &TaskRunError{t.Task, err} + return &errors.TaskRunError{TaskName: t.Task, Err: err} } } e.Logger.VerboseErrf(logger.Magenta, `task: "%s" finished`, call.Task) @@ -372,9 +373,9 @@ func (e *Executor) GetTask(call taskfile.Call) (*taskfile.Task, error) { } // If we found multiple tasks if len(aliasedTasks) > 1 { - return nil, &multipleTasksWithAliasError{ - aliasName: call.Task, - taskNames: aliasedTasks, + return nil, &errors.TaskNameConflictError{ + AliasName: call.Task, + TaskNames: aliasedTasks, } } // If we found no tasks @@ -383,9 +384,9 @@ func (e *Executor) GetTask(call taskfile.Call) (*taskfile.Task, error) { if e.fuzzyModel != nil { didYouMean = e.fuzzyModel.SpellCheck(call.Task) } - return nil, &taskNotFoundError{ - taskName: call.Task, - didYouMean: didYouMean, + return nil, &errors.TaskNotFoundError{ + TaskName: call.Task, + DidYouMean: didYouMean, } } diff --git a/task_test.go b/task_test.go index 7ca97713..0def5c0c 100644 --- a/task_test.go +++ b/task_test.go @@ -16,6 +16,7 @@ import ( "github.com/stretchr/testify/require" "github.com/go-task/task/v3" + "github.com/go-task/task/v3/errors" "github.com/go-task/task/v3/internal/filepathext" "github.com/go-task/task/v3/taskfile" ) @@ -770,7 +771,7 @@ func TestCyclicDep(t *testing.T) { Stderr: io.Discard, } require.NoError(t, e.Setup()) - assert.IsType(t, &task.MaximumTaskCallExceededError{}, e.Run(context.Background(), taskfile.Call{Task: "task-1"})) + assert.IsType(t, &errors.TaskCalledTooManyTimesError{}, e.Run(context.Background(), taskfile.Call{Task: "task-1"})) } func TestTaskVersion(t *testing.T) { @@ -1691,9 +1692,9 @@ func TestErrorCode(t *testing.T) { err := e.Run(context.Background(), taskfile.Call{Task: "test-exit-code"}) require.Error(t, err) - casted, ok := err.(*task.TaskRunError) + casted, ok := err.(*errors.TaskRunError) assert.True(t, ok, "cannot cast returned error to *task.TaskRunError") - assert.Equal(t, 42, casted.ExitCode(), "unexpected exit code from task") + assert.Equal(t, 42, casted.TaskExitCode(), "unexpected exit code from task") } func TestEvaluateSymlinksInPaths(t *testing.T) { diff --git a/taskfile/read/taskfile.go b/taskfile/read/taskfile.go index 08552a0f..0e84fd5a 100644 --- a/taskfile/read/taskfile.go +++ b/taskfile/read/taskfile.go @@ -1,7 +1,6 @@ package read import ( - "errors" "fmt" "os" "path/filepath" @@ -9,6 +8,7 @@ import ( "gopkg.in/yaml.v3" + "github.com/go-task/task/v3/errors" "github.com/go-task/task/v3/internal/filepathext" "github.com/go-task/task/v3/internal/sysinfo" "github.com/go-task/task/v3/internal/templater" @@ -208,7 +208,7 @@ func readTaskfile(file string) (*taskfile.Taskfile, error) { var t taskfile.Taskfile if err := yaml.NewDecoder(f).Decode(&t); err != nil { - return nil, fmt.Errorf("task: Failed to parse %s:\n%w", filepathext.TryAbsToRel(file), err) + return nil, &errors.TaskfileInvalidError{FilePath: filepathext.TryAbsToRel(file), Err: err} } return &t, nil } @@ -229,7 +229,7 @@ func exists(path string) (string, error) { } } - return "", fmt.Errorf(`task: No Taskfile found in "%s". Use "task --init" to create a new one`, path) + return "", errors.TaskfileNotFoundError{Dir: path, Walk: false} } func existsWalk(path string) (string, error) { @@ -254,7 +254,7 @@ func existsWalk(path string) (string, error) { // Error if we reached the root directory and still haven't found a file // OR if the user id of the directory changes if path == parentPath || (parentOwner != owner) { - return "", fmt.Errorf(`task: No Taskfile found in "%s" (or any of the parent directories). Use "task --init" to create a new one`, origPath) + return "", errors.TaskfileNotFoundError{Dir: origPath, Walk: false} } owner = parentOwner diff --git a/watch.go b/watch.go index 4f11909f..6e69f1cc 100644 --- a/watch.go +++ b/watch.go @@ -12,6 +12,7 @@ import ( "github.com/radovskyb/watcher" + "github.com/go-task/task/v3/errors" "github.com/go-task/task/v3/internal/fingerprint" "github.com/go-task/task/v3/internal/logger" "github.com/go-task/task/v3/taskfile" @@ -102,8 +103,8 @@ func (e *Executor) watchTasks(calls ...taskfile.Call) error { } func isContextError(err error) bool { - if taskRunErr, ok := err.(*TaskRunError); ok { - err = taskRunErr.err + if taskRunErr, ok := err.(*errors.TaskRunError); ok { + err = taskRunErr.Err } return err == context.Canceled || err == context.DeadlineExceeded