diff --git a/CHANGELOG.md b/CHANGELOG.md index 8d9fd18b..10a07489 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,9 @@ ## Unreleased +- Add ability to configure options for the [`set`](https://www.gnu.org/software/bash/manual/html_node/The-Set-Builtin.html) + and [`shopt`](https://www.gnu.org/software/bash/manual/html_node/The-Shopt-Builtin.html) builtins + ([#908](https://github.com/go-task/task/issues/908), [#929](https://github.com/go-task/task/pull/929) by @pd93, [Documentation](http://taskfile.dev/usage/#set-and-shopt)). - Add new `platforms:` attribute to `task` and `cmd`, so it's now possible to choose in which platforms that given task or command will be run on. Possible values are operating system (GOOS), architecture (GOARCH) or a combination of diff --git a/docs/docs/api_reference.md b/docs/docs/api_reference.md index b4404423..d2dd6fbd 100644 --- a/docs/docs/api_reference.md +++ b/docs/docs/api_reference.md @@ -91,6 +91,8 @@ Some environment variables can be overriden to adjust Task behavior. | `dotenv` | `[]string` | | A list of `.env` file paths to be parsed. | | `run` | `string` | `always` | Default 'run' option for this Taskfile. Available options: `always`, `once` and `when_changed`. | | `interval` | `string` | `5s` | Sets a different watch interval when using `--watch`, the default being 5 seconds. This string should be a valid [Go Duration](https://pkg.go.dev/time#ParseDuration). | +| `set` | `[]string` | | Specify options for the [`set` builtin](https://www.gnu.org/software/bash/manual/html_node/The-Set-Builtin.html). | +| `shopt` | `[]string` | | Specify option for the [`shopt` builtin](https://www.gnu.org/software/bash/manual/html_node/The-Shopt-Builtin.html). | ### Include @@ -140,6 +142,8 @@ includes: | `ignore_error` | `bool` | `false` | Continue execution if errors happen while executing commands. | | `run` | `string` | The one declared globally in the Taskfile or `always` | Specifies whether the task should run again or not if called more than once. Available options: `always`, `once` and `when_changed`. | | `platforms` | `[]string` | All platforms | Specifies which platforms the task should be run on. [Valid GOOS and GOARCH values allowed](https://github.com/golang/go/blob/master/src/go/build/syslist.go). Task will be skipped otherwise. | +| `set` | `[]string` | | Specify options for the [`set` builtin](https://www.gnu.org/software/bash/manual/html_node/The-Set-Builtin.html). | +| `shopt` | `[]string` | | Specify option for the [`shopt` builtin](https://www.gnu.org/software/bash/manual/html_node/The-Shopt-Builtin.html). | :::info @@ -191,6 +195,8 @@ tasks: | `ignore_error` | `bool` | `false` | Continue execution if errors happen while executing the command. | | `defer` | `string` | | Alternative to `cmd`, but schedules the command to be executed at the end of this task instead of immediately. This cannot be used together with `cmd`. | | `platforms` | `[]string` | All platforms | Specifies which platforms the command should be run on. [Valid GOOS and GOARCH values allowed](https://github.com/golang/go/blob/master/src/go/build/syslist.go). Command will be skipped otherwise. | +| `set` | `[]string` | | Specify options for the [`set` builtin](https://www.gnu.org/software/bash/manual/html_node/The-Set-Builtin.html). | +| `shopt` | `[]string` | | Specify option for the [`shopt` builtin](https://www.gnu.org/software/bash/manual/html_node/The-Shopt-Builtin.html). | :::info diff --git a/docs/docs/usage.md b/docs/docs/usage.md index a0753713..6a73b5b8 100644 --- a/docs/docs/usage.md +++ b/docs/docs/usage.md @@ -1420,6 +1420,31 @@ tasks: - ./app{{exeExt}} -h localhost -p 8080 ``` +## `set` and `shopt` + +It's possible to specify options to the +[`set`](https://www.gnu.org/software/bash/manual/html_node/The-Set-Builtin.html) +and [`shopt`](https://www.gnu.org/software/bash/manual/html_node/The-Shopt-Builtin.html) +builtins. This can be added at global, task or command level. + +```yaml +version: '2' + +set: [pipefail] +shopt: [globstar] + +tasks: + # `globstar` required for double star globs to work + default: echo **/*.go +``` + +:::info + +Keep in mind that not all options are available in the +[shell interpreter library](https://github.com/mvdan/sh) that Task uses. + +::: + ## Watch tasks With the flags `--watch` or `-w` task will watch for file changes diff --git a/docs/static/schema.json b/docs/static/schema.json index 5f967795..19b55ee6 100644 --- a/docs/static/schema.json +++ b/docs/static/schema.json @@ -108,6 +108,20 @@ "description": "The directory in which this task should run. Defaults to the current working directory.", "type": "string" }, + "set": { + "description": "Enables POSIX shell options for all of a task's commands. See https://www.gnu.org/software/bash/manual/html_node/The-Set-Builtin.html", + "type": "array", + "items": { + "$ref": "#/definitions/3/set" + } + }, + "shopt": { + "description": "Enables Bash shell options for all of a task's commands. See https://www.gnu.org/software/bash/manual/html_node/The-Shopt-Builtin.html", + "type": "array", + "items": { + "$ref": "#/definitions/3/shopt" + } + }, "vars": { "description": "A set of variables that can be used in the task.", "$ref": "#/definitions/3/vars" @@ -184,6 +198,14 @@ } ] }, + "set": { + "type": "string", + "enum": ["allexport", "a", "errexit", "e", "noexec", "n", "noglob", "f", "nounset", "u", "xtrace", "x", "pipefail"] + }, + "shopt": { + "type": "string", + "enum": ["expand_aliases", "globstar", "nullglob"] + }, "vars": { "type": "object", "patternProperties": { @@ -233,6 +255,20 @@ "description": "Silent mode disables echoing of command before Task runs it", "type": "boolean" }, + "set": { + "description": "Enables POSIX shell options for this command. See https://www.gnu.org/software/bash/manual/html_node/The-Set-Builtin.html", + "type": "array", + "items": { + "$ref": "#/definitions/3/set" + } + }, + "shopt": { + "description": "Enables Bash shell options for this command. See https://www.gnu.org/software/bash/manual/html_node/The-Shopt-Builtin.html", + "type": "array", + "items": { + "$ref": "#/definitions/3/shopt" + } + }, "ignore_error": { "description": "Prevent command from aborting the execution of task even after receiving a status code of 1", "type": "boolean" @@ -371,6 +407,20 @@ "description": "Default 'silent' options for this Taskfile. If `false`, can be overidden with `true` in a task by task basis.", "type": "boolean" }, + "set": { + "description": "Enables POSIX shell options for all commands in the Taskfile. See https://www.gnu.org/software/bash/manual/html_node/The-Set-Builtin.html", + "type": "array", + "items": { + "$ref": "#/definitions/3/set" + } + }, + "shopt": { + "description": "Enables Bash shell options for all commands in the Taskfile. See https://www.gnu.org/software/bash/manual/html_node/The-Shopt-Builtin.html", + "type": "array", + "items": { + "$ref": "#/definitions/3/shopt" + } + }, "dotenv": { "type": "array", "description": "A list of `.env` file paths to be parsed.", diff --git a/internal/execext/exec.go b/internal/execext/exec.go index d696bc25..a560ef01 100644 --- a/internal/execext/exec.go +++ b/internal/execext/exec.go @@ -3,6 +3,7 @@ package execext import ( "context" "errors" + "fmt" "io" "os" "path/filepath" @@ -17,12 +18,14 @@ import ( // RunCommandOptions is the options for the RunCommand func type RunCommandOptions struct { - Command string - Dir string - Env []string - Stdin io.Reader - Stdout io.Writer - Stderr io.Writer + Command string + Dir string + Env []string + PosixOpts []string + BashOpts []string + Stdin io.Reader + Stdout io.Writer + Stderr io.Writer } var ( @@ -36,9 +39,18 @@ func RunCommand(ctx context.Context, opts *RunCommandOptions) error { return ErrNilOptions } - p, err := syntax.NewParser().Parse(strings.NewReader(opts.Command), "") - if err != nil { - return err + // Set "-e" or "errexit" by default + opts.PosixOpts = append(opts.PosixOpts, "e") + + // Format POSIX options into a slice that mvdan/sh understands + var params []string + for _, opt := range opts.PosixOpts { + if len(opt) == 1 { + params = append(params, fmt.Sprintf("-%s", opt)) + } else { + params = append(params, "-o") + params = append(params, opt) + } } environ := opts.Env @@ -47,7 +59,7 @@ func RunCommand(ctx context.Context, opts *RunCommandOptions) error { } r, err := interp.New( - interp.Params("-e"), + interp.Params(params...), interp.Env(expand.ListEnviron(environ...)), interp.ExecHandler(interp.DefaultExecHandler(15*time.Second)), interp.OpenHandler(openHandler), @@ -58,6 +70,25 @@ func RunCommand(ctx context.Context, opts *RunCommandOptions) error { return err } + parser := syntax.NewParser() + + // Run any shopt commands + if len(opts.BashOpts) > 0 { + shoptCmdStr := fmt.Sprintf("shopt -s %s", strings.Join(opts.BashOpts, " ")) + shoptCmd, err := parser.Parse(strings.NewReader(shoptCmdStr), "") + if err != nil { + return err + } + if err := r.Run(ctx, shoptCmd); err != nil { + return err + } + } + + // Run the user-defined command + p, err := parser.Parse(strings.NewReader(opts.Command), "") + if err != nil { + return err + } return r.Run(ctx, p) } diff --git a/internal/slicesext/slicesext.go b/internal/slicesext/slicesext.go new file mode 100644 index 00000000..25b120a5 --- /dev/null +++ b/internal/slicesext/slicesext.go @@ -0,0 +1,20 @@ +package slicesext + +import ( + "golang.org/x/exp/constraints" + "golang.org/x/exp/slices" +) + +func UniqueJoin[T constraints.Ordered](ss ...[]T) []T { + var length int + for _, s := range ss { + length += len(s) + } + r := make([]T, length) + var i int + for _, s := range ss { + i += copy(r[i:], s) + } + slices.Sort(r) + return slices.Compact(r) +} diff --git a/task.go b/task.go index 80aa7a7a..4c774d4c 100644 --- a/task.go +++ b/task.go @@ -16,6 +16,7 @@ import ( "github.com/go-task/task/v3/internal/execext" "github.com/go-task/task/v3/internal/logger" "github.com/go-task/task/v3/internal/output" + "github.com/go-task/task/v3/internal/slicesext" "github.com/go-task/task/v3/internal/summary" "github.com/go-task/task/v3/internal/templater" "github.com/go-task/task/v3/taskfile" @@ -283,17 +284,19 @@ func (e *Executor) runCommand(ctx context.Context, t *taskfile.Task, call taskfi stdOut, stdErr, close := outputWrapper.WrapWriter(e.Stdout, e.Stderr, t.Prefix, outputTemplater) defer func() { if err := close(); err != nil { - e.Logger.Errf(logger.Red, "task: unable to close writter: %v", err) + e.Logger.Errf(logger.Red, "task: unable to close writer: %v", err) } }() err = execext.RunCommand(ctx, &execext.RunCommandOptions{ - Command: cmd.Cmd, - Dir: t.Dir, - Env: getEnviron(t), - Stdin: e.Stdin, - Stdout: stdOut, - Stderr: stdErr, + Command: cmd.Cmd, + Dir: t.Dir, + Env: getEnviron(t), + PosixOpts: slicesext.UniqueJoin(e.Taskfile.Set, t.Set, cmd.Set), + BashOpts: slicesext.UniqueJoin(e.Taskfile.Shopt, t.Shopt, cmd.Shopt), + Stdin: e.Stdin, + Stdout: stdOut, + Stderr: stdErr, }) if execext.IsExitError(err) && cmd.IgnoreError { e.Logger.VerboseErrf(logger.Yellow, "task: [%s] command error ignored: %v", t.Name(), err) diff --git a/task_test.go b/task_test.go index d0f89db6..9ba87e0a 100644 --- a/task_test.go +++ b/task_test.go @@ -1696,6 +1696,7 @@ func TestUserWorkingDirectory(t *testing.T) { assert.NoError(t, e.Run(context.Background(), taskfile.Call{Task: "default"})) assert.Equal(t, fmt.Sprintf("%s\n", wd), buff.String()) } + func TestPlatforms(t *testing.T) { var buff bytes.Buffer e := task.Executor{ @@ -1707,3 +1708,87 @@ func TestPlatforms(t *testing.T) { assert.NoError(t, e.Run(context.Background(), taskfile.Call{Task: "build-" + runtime.GOOS})) assert.Equal(t, fmt.Sprintf("task: [build-%s] echo 'Running task on %s'\nRunning task on %s\n", runtime.GOOS, runtime.GOOS, runtime.GOOS), buff.String()) } + +func TestPOSIXShellOptsGlobalLevel(t *testing.T) { + var buff bytes.Buffer + e := task.Executor{ + Dir: "testdata/shopts/global_level", + Stdout: &buff, + Stderr: &buff, + } + assert.NoError(t, e.Setup()) + + err := e.Run(context.Background(), taskfile.Call{Task: "pipefail"}) + assert.NoError(t, err) + assert.Equal(t, "pipefail\ton\n", buff.String()) +} + +func TestPOSIXShellOptsTaskLevel(t *testing.T) { + var buff bytes.Buffer + e := task.Executor{ + Dir: "testdata/shopts/task_level", + Stdout: &buff, + Stderr: &buff, + } + assert.NoError(t, e.Setup()) + + err := e.Run(context.Background(), taskfile.Call{Task: "pipefail"}) + assert.NoError(t, err) + assert.Equal(t, "pipefail\ton\n", buff.String()) +} + +func TestPOSIXShellOptsCommandLevel(t *testing.T) { + var buff bytes.Buffer + e := task.Executor{ + Dir: "testdata/shopts/command_level", + Stdout: &buff, + Stderr: &buff, + } + assert.NoError(t, e.Setup()) + + err := e.Run(context.Background(), taskfile.Call{Task: "pipefail"}) + assert.NoError(t, err) + assert.Equal(t, "pipefail\ton\n", buff.String()) +} + +func TestBashShellOptsGlobalLevel(t *testing.T) { + var buff bytes.Buffer + e := task.Executor{ + Dir: "testdata/shopts/global_level", + Stdout: &buff, + Stderr: &buff, + } + assert.NoError(t, e.Setup()) + + err := e.Run(context.Background(), taskfile.Call{Task: "globstar"}) + assert.NoError(t, err) + assert.Equal(t, "globstar\ton\n", buff.String()) +} + +func TestBashShellOptsTaskLevel(t *testing.T) { + var buff bytes.Buffer + e := task.Executor{ + Dir: "testdata/shopts/task_level", + Stdout: &buff, + Stderr: &buff, + } + assert.NoError(t, e.Setup()) + + err := e.Run(context.Background(), taskfile.Call{Task: "globstar"}) + assert.NoError(t, err) + assert.Equal(t, "globstar\ton\n", buff.String()) +} + +func TestBashShellOptsCommandLevel(t *testing.T) { + var buff bytes.Buffer + e := task.Executor{ + Dir: "testdata/shopts/command_level", + Stdout: &buff, + Stderr: &buff, + } + assert.NoError(t, e.Setup()) + + err := e.Run(context.Background(), taskfile.Call{Task: "globstar"}) + assert.NoError(t, err) + assert.Equal(t, "globstar\ton\n", buff.String()) +} diff --git a/taskfile/cmd.go b/taskfile/cmd.go index e2820b60..b036a6d1 100644 --- a/taskfile/cmd.go +++ b/taskfile/cmd.go @@ -11,6 +11,8 @@ type Cmd struct { Cmd string Silent bool Task string + Set []string + Shopt []string Vars *Vars IgnoreError bool Defer bool @@ -40,12 +42,16 @@ func (c *Cmd) UnmarshalYAML(node *yaml.Node) error { var cmdStruct struct { Cmd string Silent bool + Set []string + Shopt []string IgnoreError bool `yaml:"ignore_error"` Platforms []*Platform } if err := node.Decode(&cmdStruct); err == nil && cmdStruct.Cmd != "" { c.Cmd = cmdStruct.Cmd c.Silent = cmdStruct.Silent + c.Set = cmdStruct.Set + c.Shopt = cmdStruct.Shopt c.IgnoreError = cmdStruct.IgnoreError c.Platforms = cmdStruct.Platforms return nil diff --git a/taskfile/task.go b/taskfile/task.go index b6367007..95b4c1b6 100644 --- a/taskfile/task.go +++ b/taskfile/task.go @@ -23,6 +23,8 @@ type Task struct { Status []string Preconditions []*Precondition Dir string + Set []string + Shopt []string Vars *Vars Env *Vars Dotenv []string @@ -81,6 +83,8 @@ func (t *Task) UnmarshalYAML(node *yaml.Node) error { Status []string Preconditions []*Precondition Dir string + Set []string + Shopt []string Vars *Vars Env *Vars Dotenv []string @@ -107,6 +111,8 @@ func (t *Task) UnmarshalYAML(node *yaml.Node) error { t.Status = task.Status t.Preconditions = task.Preconditions t.Dir = task.Dir + t.Set = task.Set + t.Shopt = task.Shopt t.Vars = task.Vars t.Env = task.Env t.Dotenv = task.Dotenv @@ -140,6 +146,8 @@ func (t *Task) DeepCopy() *Task { Status: deepCopySlice(t.Status), Preconditions: deepCopySlice(t.Preconditions), Dir: t.Dir, + Set: deepCopySlice(t.Set), + Shopt: deepCopySlice(t.Shopt), Vars: t.Vars.DeepCopy(), Env: t.Env.DeepCopy(), Dotenv: deepCopySlice(t.Dotenv), diff --git a/taskfile/taskfile.go b/taskfile/taskfile.go index fe8b3e84..58367730 100644 --- a/taskfile/taskfile.go +++ b/taskfile/taskfile.go @@ -15,6 +15,8 @@ type Taskfile struct { Output Output Method string Includes *IncludedTaskfiles + Set []string + Shopt []string Vars *Vars Env *Vars Tasks Tasks @@ -34,6 +36,8 @@ func (tf *Taskfile) UnmarshalYAML(node *yaml.Node) error { Output Output Method string Includes *IncludedTaskfiles + Set []string + Shopt []string Vars *Vars Env *Vars Tasks Tasks @@ -50,6 +54,8 @@ func (tf *Taskfile) UnmarshalYAML(node *yaml.Node) error { tf.Output = taskfile.Output tf.Method = taskfile.Method tf.Includes = taskfile.Includes + tf.Set = taskfile.Set + tf.Shopt = taskfile.Shopt tf.Vars = taskfile.Vars tf.Env = taskfile.Env tf.Tasks = taskfile.Tasks diff --git a/testdata/shopts/command_level/Taskfile.yml b/testdata/shopts/command_level/Taskfile.yml new file mode 100644 index 00000000..24a8b089 --- /dev/null +++ b/testdata/shopts/command_level/Taskfile.yml @@ -0,0 +1,14 @@ +version: '3' + +silent: true + +tasks: + pipefail: + cmds: + - cmd: set -o | grep pipefail + set: [pipefail] + + globstar: + cmds: + - cmd: shopt | grep globstar + shopt: [globstar] diff --git a/testdata/shopts/global_level/Taskfile.yml b/testdata/shopts/global_level/Taskfile.yml new file mode 100644 index 00000000..be6e2750 --- /dev/null +++ b/testdata/shopts/global_level/Taskfile.yml @@ -0,0 +1,14 @@ +version: '3' + +silent: true +set: [pipefail] +shopt: [globstar] + +tasks: + pipefail: + cmds: + - set -o | grep pipefail + + globstar: + cmds: + - shopt | grep globstar diff --git a/testdata/shopts/task_level/Taskfile.yml b/testdata/shopts/task_level/Taskfile.yml new file mode 100644 index 00000000..7d449aeb --- /dev/null +++ b/testdata/shopts/task_level/Taskfile.yml @@ -0,0 +1,14 @@ +version: '3' + +silent: true + +tasks: + pipefail: + set: [pipefail] + cmds: + - set -o | grep pipefail + + globstar: + shopt: [globstar] + cmds: + - shopt | grep globstar diff --git a/variables.go b/variables.go index cff6e152..a1fa917a 100644 --- a/variables.go +++ b/variables.go @@ -56,6 +56,8 @@ func (e *Executor) compiledTask(call taskfile.Call, evaluateShVars bool) (*taskf Sources: r.ReplaceSlice(origTask.Sources), Generates: r.ReplaceSlice(origTask.Generates), Dir: r.Replace(origTask.Dir), + Set: origTask.Set, + Shopt: origTask.Shopt, Vars: nil, Env: nil, Dotenv: r.ReplaceSlice(origTask.Dotenv), @@ -125,9 +127,11 @@ func (e *Executor) compiledTask(call taskfile.Call, evaluateShVars bool) (*taskf continue } new.Cmds = append(new.Cmds, &taskfile.Cmd{ - Task: r.Replace(cmd.Task), - Silent: cmd.Silent, Cmd: r.Replace(cmd.Cmd), + Silent: cmd.Silent, + Task: r.Replace(cmd.Task), + Set: cmd.Set, + Shopt: cmd.Shopt, Vars: r.ReplaceVars(cmd.Vars), IgnoreError: cmd.IgnoreError, Defer: cmd.Defer,