1
0
mirror of https://github.com/go-task/task.git synced 2024-12-04 10:24:45 +02:00

feat: custom error codes (#1114)

This commit is contained in:
Pete Davison 2023-04-15 21:22:25 +01:00 committed by GitHub
parent 9ec544817f
commit f9c77acd96
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
12 changed files with 286 additions and 129 deletions

View File

@ -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

View File

@ -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) {

View File

@ -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

View File

@ -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,
)
}

40
errors/errors.go Normal file
View File

@ -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)
}

100
errors/errors_task.go Normal file
View File

@ -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
}

51
errors/errors_taskfile.go Normal file
View File

@ -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
}

View File

@ -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 {

23
task.go
View File

@ -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,
}
}

View File

@ -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) {

View File

@ -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

View File

@ -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