diff --git a/executor_test.go b/executor_test.go index 6533e19c08..c992b42907 100644 --- a/executor_test.go +++ b/executor_test.go @@ -6,6 +6,7 @@ import ( "fmt" "os" "path/filepath" + "regexp" "testing" "github.com/sebdah/goldie/v2" @@ -1325,3 +1326,62 @@ func TestIf(t *testing.T) { NewExecutorTest(t, opts...) } } + +var ansiEscape = regexp.MustCompile(`\x1b\[[0-9;]*m`) + +func TestAutomaticOverride(t *testing.T) { + t.Parallel() + const dir = "testdata/override" + NewExecutorTest(t, + WithName("discovered"), + WithExecutorOptions(task.WithDir(dir), task.WithSilent(true)), + WithTask("dev"), + ) + NewExecutorTest(t, + WithName("added task location"), + WithExecutorOptions(task.WithDir(dir), task.WithSilent(true)), + WithTask("added"), + WithFixtureTemplating(), + ) + // The invalid override in child/ must be ignored. + NewExecutorTest(t, + WithName("subdirectory"), + WithExecutorOptions(task.WithDir(dir+"/child"), task.WithSilent(true)), + WithTask("dev"), + ) + NewExecutorTest(t, + WithName("merge"), + WithExecutorOptions(task.WithDir(dir+"/merge"), task.WithSilent(true)), + WithTask("dev"), + ) + for _, include := range []string{"docs", "extra"} { + NewExecutorTest(t, + WithName("include "+include), + WithExecutorOptions(task.WithDir(dir+"/includes"), task.WithSilent(true)), + WithTask(include+":build"), + ) + } + for _, name := range []string{"invalid_yaml", "missing_version", "newer_version", "forbidden_key", "redefined_include"} { + NewExecutorTest(t, + WithName("error "+name), + WithExecutorOptions(task.WithDir(dir+"/errors/"+name)), + WithSetupError(), + WithFixtureTemplating(), + // Decode errors print the raw path and a colored snippet. + WithPostProcessFn(func(_ *testing.T, b []byte) []byte { + b = ansiEscape.ReplaceAll(b, nil) + return bytes.ReplaceAll(b, []byte(`\`), []byte("/")) + }), + ) + } + NewExecutorTest(t, + WithName("explicit file"), + WithExecutorOptions(task.WithDir(dir), task.WithEntrypoint(dir+"/Taskfile.yml"), task.WithSilent(true)), + WithTask("dev"), + ) + NewExecutorTest(t, + WithName("explicit directory"), + WithExecutorOptions(task.WithDir(dir), task.WithEntrypoint(dir), task.WithSilent(true)), + WithTask("dev"), + ) +} diff --git a/setup.go b/setup.go index 17c01ed12f..3a4a85158b 100644 --- a/setup.go +++ b/setup.go @@ -25,6 +25,7 @@ import ( func (e *Executor) Setup() error { e.setupLogger() + automaticOverride := discoversTaskfile(e.Entrypoint) node, err := e.getRootNode() if err != nil { return err @@ -32,7 +33,7 @@ func (e *Executor) Setup() error { if err := e.setupTempDir(); err != nil { return err } - if err := e.readTaskfile(node); err != nil { + if err := e.readTaskfile(node, automaticOverride); err != nil { return err } e.setupStdFiles() @@ -53,6 +54,16 @@ func (e *Executor) Setup() error { return nil } +// discoversTaskfile reports whether Task picks the Taskfile name itself, as +// opposed to the user naming the file. +func discoversTaskfile(entrypoint string) bool { + if entrypoint == "" { + return true + } + info, err := os.Stat(entrypoint) + return err == nil && info.IsDir() +} + func (e *Executor) getRootNode() (taskfile.Node, error) { node, err := taskfile.NewRootNode(e.Entrypoint, e.Dir, e.Insecure, e.Timeout, taskfile.WithCACert(e.CACert), @@ -72,7 +83,7 @@ func (e *Executor) getRootNode() (taskfile.Node, error) { return node, err } -func (e *Executor) readTaskfile(node taskfile.Node) error { +func (e *Executor) readTaskfile(node taskfile.Node, automaticOverride bool) error { ctx, cf := context.WithTimeout(context.Background(), e.Timeout) defer cf() debugFunc := func(s string) { @@ -82,6 +93,7 @@ func (e *Executor) readTaskfile(node taskfile.Node) error { return e.Logger.Prompt(logger.Yellow, s, "n", "y", "yes") } reader := taskfile.NewReader( + taskfile.WithAutomaticOverride(automaticOverride), taskfile.WithInsecure(e.Insecure), taskfile.WithDownload(e.Download), taskfile.WithOffline(e.Offline), diff --git a/taskfile/ast/override.go b/taskfile/ast/override.go new file mode 100644 index 0000000000..ed9afd8517 --- /dev/null +++ b/taskfile/ast/override.go @@ -0,0 +1,66 @@ +package ast + +import ( + "fmt" + "path/filepath" + "slices" + + "github.com/go-task/task/v3/errors" +) + +// MergeOverride applies a local Taskfile override to tf. +func (tf *Taskfile) MergeOverride(override *Taskfile) error { + // An older version is fine, so bumping the shared Taskfile doesn't break + // existing overrides. + if override.Version.Major() != tf.Version.Major() || override.Version.GreaterThan(tf.Version) { + return &errors.TaskfileVersionCheckError{ + URI: override.Location, + SchemaVersion: override.Version, + Message: fmt.Sprintf("must be a v%d version no newer than the main Taskfile (%s)", tf.Version.Major(), tf.Version), + } + } + if override.Output.IsSet() { + tf.Output = override.Output + } + if override.Method != "" { + tf.Method = override.Method + } + if override.Run != "" { + tf.Run = override.Run + } + if override.Interval != 0 { + tf.Interval = override.Interval + } + if override.UseGitignore != nil { + tf.UseGitignore = override.UseGitignore + } + // A present but empty list clears the main one. + if override.Set != nil { + tf.Set = override.Set + } + if override.Shopt != nil { + tf.Shopt = override.Shopt + } + for namespace, include := range override.Includes.All() { + if _, ok := tf.Includes.Get(namespace); ok { + return fmt.Errorf("task: Taskfile override %q can't redefine include %q", filepath.ToSlash(override.Location), namespace) + } + tf.Includes.Set(namespace, include) + } + if tf.Vars == nil { + tf.Vars = NewVars() + } + if tf.Env == nil { + tf.Env = NewVars() + } + if tf.Tasks == nil { + tf.Tasks = NewTasks() + } + tf.Vars.Merge(override.Vars, nil) + tf.Env.Merge(override.Env, nil) + tf.Dotenv = append(slices.Clone(override.Dotenv), tf.Dotenv...) + for name, task := range override.Tasks.All(nil) { + tf.Tasks.Set(name, task) + } + return nil +} diff --git a/taskfile/options.go b/taskfile/options.go index badf814c29..0d6b2316e6 100644 --- a/taskfile/options.go +++ b/taskfile/options.go @@ -2,6 +2,20 @@ package taskfile import "time" +// WithAutomaticOverride loads a Taskfile override beside a local root Taskfile. +// Remote, stdin and included Taskfiles never load one. +func WithAutomaticOverride(enabled bool) *automaticOverrideOption { + return &automaticOverrideOption{enabled: enabled} +} + +type automaticOverrideOption struct { + enabled bool +} + +func (o *automaticOverrideOption) ApplyToReader(r *Reader) { + r.automaticOverride = o.enabled +} + // WithInsecure allows the [Reader] to make insecure connections when reading // remote taskfiles. By default, insecure connections are rejected. func WithInsecure(insecure bool) *insecureOption { diff --git a/taskfile/override.go b/taskfile/override.go new file mode 100644 index 0000000000..cc25f5a367 --- /dev/null +++ b/taskfile/override.go @@ -0,0 +1,66 @@ +package taskfile + +import ( + "fmt" + "os" + "path/filepath" + + "go.yaml.in/yaml/v3" + + "github.com/go-task/task/v3/errors" + "github.com/go-task/task/v3/taskfile/ast" +) + +var defaultOverrides = []string{ + "Taskfile.override.yml", + "taskfile.override.yml", + "Taskfile.override.yaml", + "taskfile.override.yaml", +} + +func (r *Reader) loadsOverride(node Node) bool { + _, ok := node.(*FileNode) + return ok && node.Parent() == nil && r.automaticOverride +} + +func readAutomaticOverride(dir string, tf *ast.Taskfile) error { + for _, name := range defaultOverrides { + path := filepath.Join(dir, name) + // Check existence separately so a dangling symlink is an error too. + if _, err := os.Lstat(path); errors.Is(err, os.ErrNotExist) { + continue + } else if err != nil { + return fmt.Errorf("task: Failed to read Taskfile override: %w", err) + } + b, err := os.ReadFile(path) + if err != nil { + return fmt.Errorf("task: Failed to read Taskfile override: %w", err) + } + var override ast.Taskfile + if err := decodeTaskfile(b, path, &override, (*taskfileOverride)(&override)); err != nil { + return err + } + return tf.MergeOverride(&override) + } + return nil +} + +// taskfileOverride restricts the root fields while using the normal Taskfile +// decoders for their values. +type taskfileOverride ast.Taskfile + +func (tf *taskfileOverride) UnmarshalYAML(node *yaml.Node) error { + if node.Kind != yaml.MappingNode { + return errors.NewTaskfileDecodeError(nil, node).WithTypeMessage("taskfile override") + } + for i := 0; i < len(node.Content); i += 2 { + key := node.Content[i] + switch key.Value { + case "version", "output", "method", "includes", "set", "shopt", "vars", "env", "dotenv", "tasks", "run", "interval", "use_gitignore": + default: + return errors.NewTaskfileDecodeError(nil, key). + WithMessage("field %q is not allowed in a Taskfile override", key.Value) + } + } + return (*ast.Taskfile)(tf).UnmarshalYAML(node) +} diff --git a/taskfile/override_test.go b/taskfile/override_test.go new file mode 100644 index 0000000000..870f652b63 --- /dev/null +++ b/taskfile/override_test.go @@ -0,0 +1,77 @@ +package taskfile + +import ( + "fmt" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func writeOverrideFixture(t *testing.T, dir, name, content string) { + t.Helper() + require.NoError(t, os.WriteFile(filepath.Join(dir, name), []byte(content), 0o600)) +} + +// Settings have no visible effect on a task's output, so they are checked on +// the merged Taskfile. +func TestAutomaticOverrideSettings(t *testing.T) { + t.Parallel() + dir := t.TempDir() + writeOverrideFixture(t, dir, "Taskfile.yml", `version: '3' +method: timestamp +run: once +interval: 1s +set: [pipefail] +shopt: [globstar] +use_gitignore: true +`) + writeOverrideFixture(t, dir, "Taskfile.override.yml", `version: '3' +method: none +run: when_changed +interval: 2s +set: [] +use_gitignore: false +`) + node, err := NewFileNode("", dir) + require.NoError(t, err) + graph, err := NewReader(WithAutomaticOverride(true)).Read(t.Context(), node) + require.NoError(t, err) + tf, err := graph.Merge() + require.NoError(t, err) + assert.Equal(t, "none", tf.Method) + assert.Equal(t, "when_changed", tf.Run) + assert.Equal(t, 2*time.Second, tf.Interval) + assert.Equal(t, []string{}, tf.Set) + assert.Equal(t, []string{"globstar"}, tf.Shopt) + assert.Equal(t, new(false), tf.UseGitignore) +} + +func TestAutomaticOverrideRemote(t *testing.T) { + t.Parallel() + dir := t.TempDir() + writeOverrideFixture(t, dir, "Taskfile.override.yml", "invalid YAML: [") + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = fmt.Fprint(w, "version: '3'\n") + })) + defer server.Close() + node, err := NewHTTPNode(server.URL+"/Taskfile.yml", dir, true) + require.NoError(t, err) + _, err = NewReader(WithAutomaticOverride(true), WithTempDir(t.TempDir())).Read(t.Context(), node) + require.NoError(t, err) +} + +func TestAutomaticOverrideStdin(t *testing.T) { //nolint:paralleltest // replaces process-wide stdin + dir := t.TempDir() + writeOverrideFixture(t, dir, "Taskfile.override.yml", "invalid YAML: [") + replaceStdin(t, []byte("version: '3'\n")) + node, err := NewStdinNode(dir) + require.NoError(t, err) + _, err = NewReader(WithAutomaticOverride(true)).Read(t.Context(), node) + require.NoError(t, err) +} diff --git a/taskfile/reader.go b/taskfile/reader.go index d84abc80e1..1aab3cb62a 100644 --- a/taskfile/reader.go +++ b/taskfile/reader.go @@ -5,6 +5,7 @@ import ( "fmt" "net/url" "os" + "path/filepath" "slices" "sync" "time" @@ -42,6 +43,7 @@ type ( // [ast.TaskfileGraph] from them. Reader struct { graph *ast.TaskfileGraph + automaticOverride bool insecure bool download bool offline bool @@ -158,6 +160,11 @@ func (r *Reader) include(ctx context.Context, node Node) error { if err != nil { return err } + if r.loadsOverride(node) { + if err := readAutomaticOverride(filepath.Dir(node.Location()), vertex.Taskfile); err != nil { + return err + } + } // Create an error group to wait for all included Taskfiles to be read var g errgroup.Group @@ -259,7 +266,16 @@ func (r *Reader) readNode(ctx context.Context, node Node) (*ast.Taskfile, error) } var tf ast.Taskfile - if err := yaml.Unmarshal(b, &tf); err != nil { + if err := decodeTaskfile(b, node.Location(), &tf, &tf); err != nil { + return nil, err + } + return &tf, nil +} + +// decodeTaskfile unmarshals b into tf through target, a view of tf that may +// restrict which fields are accepted. +func decodeTaskfile(b []byte, location string, tf *ast.Taskfile, target yaml.Unmarshaler) error { + if err := yaml.Unmarshal(b, target); err != nil { // Decode the taskfile and add the file info the any errors if taskfileDecodeErr, ok := errors.AsType[*errors.TaskfileDecodeError](err); ok { snippet := NewSnippet(b, @@ -267,18 +283,18 @@ func (r *Reader) readNode(ctx context.Context, node Node) (*ast.Taskfile, error) WithColumn(taskfileDecodeErr.Column), WithPadding(2), ) - return nil, taskfileDecodeErr.WithFileInfo(node.Location(), snippet.String()) + return taskfileDecodeErr.WithFileInfo(location, snippet.String()) } - return nil, &errors.TaskfileInvalidError{URI: filepathext.TryAbsToRel(node.Location()), Err: err} + return &errors.TaskfileInvalidError{URI: filepathext.TryAbsToRel(location), Err: err} } // Check that the Taskfile is set and has a schema version if tf.Version == nil { - return nil, &errors.TaskfileVersionCheckError{URI: node.Location()} + return &errors.TaskfileVersionCheckError{URI: location} } // Set the taskfile/task's locations - tf.Location = node.Location() + tf.Location = location for task := range tf.Tasks.Values(nil) { // If the task is not defined, create a new one if task == nil { @@ -290,7 +306,7 @@ func (r *Reader) readNode(ctx context.Context, node Node) (*ast.Taskfile, error) } } - return &tf, nil + return nil } func (r *Reader) readNodeContent(ctx context.Context, node Node) ([]byte, error) { diff --git a/testdata/override/.env b/testdata/override/.env new file mode 100644 index 0000000000..d60354e0f4 --- /dev/null +++ b/testdata/override/.env @@ -0,0 +1 @@ +TASK_OVERRIDE_DOTENV=shared diff --git a/testdata/override/.env.local b/testdata/override/.env.local new file mode 100644 index 0000000000..0c719ae66a --- /dev/null +++ b/testdata/override/.env.local @@ -0,0 +1 @@ +TASK_OVERRIDE_DOTENV=local diff --git a/testdata/override/Taskfile.override.yml b/testdata/override/Taskfile.override.yml new file mode 100644 index 0000000000..3d61122a5a --- /dev/null +++ b/testdata/override/Taskfile.override.yml @@ -0,0 +1,16 @@ +version: '3' + +output: prefixed + +vars: + PORT: 4000 + +env: + TASK_OVERRIDE_ENV: local + +dotenv: [.env.local] + +tasks: + added: + cmds: + - echo {{.TASKFILE}} {{.ROOT_TASKFILE}} diff --git a/testdata/override/Taskfile.yml b/testdata/override/Taskfile.yml new file mode 100644 index 0000000000..28ff8952cb --- /dev/null +++ b/testdata/override/Taskfile.yml @@ -0,0 +1,14 @@ +version: '3' + +vars: + PORT: 3000 + +env: + TASK_OVERRIDE_ENV: main + +dotenv: [.env] + +tasks: + dev: + cmds: + - echo {{.PORT}} $TASK_OVERRIDE_ENV $TASK_OVERRIDE_DOTENV diff --git a/testdata/override/child/Taskfile.override.yml b/testdata/override/child/Taskfile.override.yml new file mode 100644 index 0000000000..5e69b514b7 --- /dev/null +++ b/testdata/override/child/Taskfile.override.yml @@ -0,0 +1 @@ +bad: [ diff --git a/testdata/override/child/testdata/TestAutomaticOverride-subdirectory.golden b/testdata/override/child/testdata/TestAutomaticOverride-subdirectory.golden new file mode 100644 index 0000000000..75840c7e30 --- /dev/null +++ b/testdata/override/child/testdata/TestAutomaticOverride-subdirectory.golden @@ -0,0 +1 @@ +[dev] 4000 local local diff --git a/testdata/override/errors/forbidden_key/Taskfile.override.yml b/testdata/override/errors/forbidden_key/Taskfile.override.yml new file mode 100644 index 0000000000..6993fe0696 --- /dev/null +++ b/testdata/override/errors/forbidden_key/Taskfile.override.yml @@ -0,0 +1,2 @@ +version: '3' +silent: true diff --git a/testdata/override/errors/forbidden_key/Taskfile.yml b/testdata/override/errors/forbidden_key/Taskfile.yml new file mode 100644 index 0000000000..7e69b80f34 --- /dev/null +++ b/testdata/override/errors/forbidden_key/Taskfile.yml @@ -0,0 +1,4 @@ +version: '3' + +tasks: + default: echo main diff --git a/testdata/override/errors/forbidden_key/testdata/TestAutomaticOverride-error_forbidden_key-err-setup.golden b/testdata/override/errors/forbidden_key/testdata/TestAutomaticOverride-error_forbidden_key-err-setup.golden new file mode 100644 index 0000000000..efc5899b5e --- /dev/null +++ b/testdata/override/errors/forbidden_key/testdata/TestAutomaticOverride-error_forbidden_key-err-setup.golden @@ -0,0 +1,5 @@ +err: field "silent" is not allowed in a Taskfile override +file: {{.TEST_DIR}}/testdata/override/errors/forbidden_key/Taskfile.override.yml:2:1 + 1 | version: '3' +> 2 | silent: true + | ^ \ No newline at end of file diff --git a/testdata/override/errors/forbidden_key/testdata/TestAutomaticOverride-error_forbidden_key.golden b/testdata/override/errors/forbidden_key/testdata/TestAutomaticOverride-error_forbidden_key.golden new file mode 100644 index 0000000000..e69de29bb2 diff --git a/testdata/override/errors/invalid_yaml/Taskfile.override.yml b/testdata/override/errors/invalid_yaml/Taskfile.override.yml new file mode 100644 index 0000000000..f313c90d77 --- /dev/null +++ b/testdata/override/errors/invalid_yaml/Taskfile.override.yml @@ -0,0 +1 @@ +version: [ diff --git a/testdata/override/errors/invalid_yaml/Taskfile.yml b/testdata/override/errors/invalid_yaml/Taskfile.yml new file mode 100644 index 0000000000..7e69b80f34 --- /dev/null +++ b/testdata/override/errors/invalid_yaml/Taskfile.yml @@ -0,0 +1,4 @@ +version: '3' + +tasks: + default: echo main diff --git a/testdata/override/errors/invalid_yaml/testdata/TestAutomaticOverride-error_invalid_yaml-err-setup.golden b/testdata/override/errors/invalid_yaml/testdata/TestAutomaticOverride-error_invalid_yaml-err-setup.golden new file mode 100644 index 0000000000..085760b0b8 --- /dev/null +++ b/testdata/override/errors/invalid_yaml/testdata/TestAutomaticOverride-error_invalid_yaml-err-setup.golden @@ -0,0 +1,2 @@ +task: Failed to parse testdata/override/errors/invalid_yaml/Taskfile.override.yml: +yaml: line 1: did not find expected node content \ No newline at end of file diff --git a/testdata/override/errors/invalid_yaml/testdata/TestAutomaticOverride-error_invalid_yaml.golden b/testdata/override/errors/invalid_yaml/testdata/TestAutomaticOverride-error_invalid_yaml.golden new file mode 100644 index 0000000000..e69de29bb2 diff --git a/testdata/override/errors/missing_version/Taskfile.override.yml b/testdata/override/errors/missing_version/Taskfile.override.yml new file mode 100644 index 0000000000..a0d5075b7e --- /dev/null +++ b/testdata/override/errors/missing_version/Taskfile.override.yml @@ -0,0 +1,2 @@ +vars: + PORT: 4000 diff --git a/testdata/override/errors/missing_version/Taskfile.yml b/testdata/override/errors/missing_version/Taskfile.yml new file mode 100644 index 0000000000..7e69b80f34 --- /dev/null +++ b/testdata/override/errors/missing_version/Taskfile.yml @@ -0,0 +1,4 @@ +version: '3' + +tasks: + default: echo main diff --git a/testdata/override/errors/missing_version/testdata/TestAutomaticOverride-error_missing_version-err-setup.golden b/testdata/override/errors/missing_version/testdata/TestAutomaticOverride-error_missing_version-err-setup.golden new file mode 100644 index 0000000000..0e4e4c7384 --- /dev/null +++ b/testdata/override/errors/missing_version/testdata/TestAutomaticOverride-error_missing_version-err-setup.golden @@ -0,0 +1 @@ +task: Missing schema version in Taskfile "{{.TEST_DIR}}/testdata/override/errors/missing_version/Taskfile.override.yml" \ No newline at end of file diff --git a/testdata/override/errors/missing_version/testdata/TestAutomaticOverride-error_missing_version.golden b/testdata/override/errors/missing_version/testdata/TestAutomaticOverride-error_missing_version.golden new file mode 100644 index 0000000000..e69de29bb2 diff --git a/testdata/override/errors/newer_version/Taskfile.override.yml b/testdata/override/errors/newer_version/Taskfile.override.yml new file mode 100644 index 0000000000..292963ede4 --- /dev/null +++ b/testdata/override/errors/newer_version/Taskfile.override.yml @@ -0,0 +1 @@ +version: '3.1' diff --git a/testdata/override/errors/newer_version/Taskfile.yml b/testdata/override/errors/newer_version/Taskfile.yml new file mode 100644 index 0000000000..7e69b80f34 --- /dev/null +++ b/testdata/override/errors/newer_version/Taskfile.yml @@ -0,0 +1,4 @@ +version: '3' + +tasks: + default: echo main diff --git a/testdata/override/errors/newer_version/testdata/TestAutomaticOverride-error_newer_version-err-setup.golden b/testdata/override/errors/newer_version/testdata/TestAutomaticOverride-error_newer_version-err-setup.golden new file mode 100644 index 0000000000..7222e304d1 --- /dev/null +++ b/testdata/override/errors/newer_version/testdata/TestAutomaticOverride-error_newer_version-err-setup.golden @@ -0,0 +1,2 @@ +task: Invalid schema version in Taskfile "{{.TEST_DIR}}/testdata/override/errors/newer_version/Taskfile.override.yml": +Schema version (3.1.0) must be a v3 version no newer than the main Taskfile (3.0.0) \ No newline at end of file diff --git a/testdata/override/errors/newer_version/testdata/TestAutomaticOverride-error_newer_version.golden b/testdata/override/errors/newer_version/testdata/TestAutomaticOverride-error_newer_version.golden new file mode 100644 index 0000000000..e69de29bb2 diff --git a/testdata/override/errors/redefined_include/Taskfile.override.yml b/testdata/override/errors/redefined_include/Taskfile.override.yml new file mode 100644 index 0000000000..5f32ff547a --- /dev/null +++ b/testdata/override/errors/redefined_include/Taskfile.override.yml @@ -0,0 +1,4 @@ +version: '3' + +includes: + docs: ./other diff --git a/testdata/override/errors/redefined_include/Taskfile.yml b/testdata/override/errors/redefined_include/Taskfile.yml new file mode 100644 index 0000000000..178f74ddd8 --- /dev/null +++ b/testdata/override/errors/redefined_include/Taskfile.yml @@ -0,0 +1,4 @@ +version: '3' + +includes: + docs: ./docs diff --git a/testdata/override/errors/redefined_include/testdata/TestAutomaticOverride-error_redefined_include-err-setup.golden b/testdata/override/errors/redefined_include/testdata/TestAutomaticOverride-error_redefined_include-err-setup.golden new file mode 100644 index 0000000000..d955ea2e9a --- /dev/null +++ b/testdata/override/errors/redefined_include/testdata/TestAutomaticOverride-error_redefined_include-err-setup.golden @@ -0,0 +1 @@ +task: Taskfile override "{{.TEST_DIR}}/testdata/override/errors/redefined_include/Taskfile.override.yml" can't redefine include "docs" \ No newline at end of file diff --git a/testdata/override/errors/redefined_include/testdata/TestAutomaticOverride-error_redefined_include.golden b/testdata/override/errors/redefined_include/testdata/TestAutomaticOverride-error_redefined_include.golden new file mode 100644 index 0000000000..e69de29bb2 diff --git a/testdata/override/includes/Taskfile.override.yml b/testdata/override/includes/Taskfile.override.yml new file mode 100644 index 0000000000..1fa758a240 --- /dev/null +++ b/testdata/override/includes/Taskfile.override.yml @@ -0,0 +1,7 @@ +version: '3' + +vars: + INCLUDE: docs + +includes: + extra: ./extra diff --git a/testdata/override/includes/Taskfile.yml b/testdata/override/includes/Taskfile.yml new file mode 100644 index 0000000000..fd60c30683 --- /dev/null +++ b/testdata/override/includes/Taskfile.yml @@ -0,0 +1,7 @@ +version: '3' + +vars: + INCLUDE: missing + +includes: + docs: ./{{.INCLUDE}} diff --git a/testdata/override/includes/docs/Taskfile.override.yml b/testdata/override/includes/docs/Taskfile.override.yml new file mode 100644 index 0000000000..5e69b514b7 --- /dev/null +++ b/testdata/override/includes/docs/Taskfile.override.yml @@ -0,0 +1 @@ +bad: [ diff --git a/testdata/override/includes/docs/Taskfile.yml b/testdata/override/includes/docs/Taskfile.yml new file mode 100644 index 0000000000..f10aa1f668 --- /dev/null +++ b/testdata/override/includes/docs/Taskfile.yml @@ -0,0 +1,4 @@ +version: '3' + +tasks: + build: echo docs diff --git a/testdata/override/includes/extra/Taskfile.yml b/testdata/override/includes/extra/Taskfile.yml new file mode 100644 index 0000000000..4ae18bcebb --- /dev/null +++ b/testdata/override/includes/extra/Taskfile.yml @@ -0,0 +1,4 @@ +version: '3' + +tasks: + build: echo extra diff --git a/testdata/override/includes/testdata/TestAutomaticOverride-include_docs.golden b/testdata/override/includes/testdata/TestAutomaticOverride-include_docs.golden new file mode 100644 index 0000000000..d8f8d46921 --- /dev/null +++ b/testdata/override/includes/testdata/TestAutomaticOverride-include_docs.golden @@ -0,0 +1 @@ +docs diff --git a/testdata/override/includes/testdata/TestAutomaticOverride-include_extra.golden b/testdata/override/includes/testdata/TestAutomaticOverride-include_extra.golden new file mode 100644 index 0000000000..0f2287157f --- /dev/null +++ b/testdata/override/includes/testdata/TestAutomaticOverride-include_extra.golden @@ -0,0 +1 @@ +extra diff --git a/testdata/override/merge/Taskfile.override.yml b/testdata/override/merge/Taskfile.override.yml new file mode 100644 index 0000000000..001ac4f30a --- /dev/null +++ b/testdata/override/merge/Taskfile.override.yml @@ -0,0 +1,15 @@ +version: '3' + +vars: + PORT: 4000 + CONFIG: + map: {shared: local} + NEW: local + +env: + FIRST: local + +tasks: + dev: + cmds: + - echo {{.PORT}} {{.KEEP}} {{.CONFIG}} {{.NEW}} $FIRST $KEEP_ENV diff --git a/testdata/override/merge/Taskfile.yml b/testdata/override/merge/Taskfile.yml new file mode 100644 index 0000000000..18b91ac18f --- /dev/null +++ b/testdata/override/merge/Taskfile.yml @@ -0,0 +1,19 @@ +version: '3' + +vars: + PORT: 3000 + KEEP: original + CONFIG: + map: {old: old, shared: original} + +env: + FIRST: original + KEEP_ENV: original + +tasks: + dev: + deps: [fail] + cmds: + - exit 1 + + fail: exit 1 diff --git a/testdata/override/merge/testdata/TestAutomaticOverride-merge.golden b/testdata/override/merge/testdata/TestAutomaticOverride-merge.golden new file mode 100644 index 0000000000..4416be4020 --- /dev/null +++ b/testdata/override/merge/testdata/TestAutomaticOverride-merge.golden @@ -0,0 +1 @@ +4000 original map[shared:local] local local original diff --git a/testdata/override/testdata/TestAutomaticOverride-added_task_location.golden b/testdata/override/testdata/TestAutomaticOverride-added_task_location.golden new file mode 100644 index 0000000000..ac40c9185c --- /dev/null +++ b/testdata/override/testdata/TestAutomaticOverride-added_task_location.golden @@ -0,0 +1 @@ +[added] {{.TEST_DIR}}/testdata/override/Taskfile.override.yml {{.TEST_DIR}}/testdata/override/Taskfile.yml diff --git a/testdata/override/testdata/TestAutomaticOverride-discovered.golden b/testdata/override/testdata/TestAutomaticOverride-discovered.golden new file mode 100644 index 0000000000..75840c7e30 --- /dev/null +++ b/testdata/override/testdata/TestAutomaticOverride-discovered.golden @@ -0,0 +1 @@ +[dev] 4000 local local diff --git a/testdata/override/testdata/TestAutomaticOverride-explicit_directory.golden b/testdata/override/testdata/TestAutomaticOverride-explicit_directory.golden new file mode 100644 index 0000000000..75840c7e30 --- /dev/null +++ b/testdata/override/testdata/TestAutomaticOverride-explicit_directory.golden @@ -0,0 +1 @@ +[dev] 4000 local local diff --git a/testdata/override/testdata/TestAutomaticOverride-explicit_file.golden b/testdata/override/testdata/TestAutomaticOverride-explicit_file.golden new file mode 100644 index 0000000000..f15a0af2e9 --- /dev/null +++ b/testdata/override/testdata/TestAutomaticOverride-explicit_file.golden @@ -0,0 +1 @@ +3000 main shared diff --git a/website/src/next/docs/guide/running-tasks.md b/website/src/next/docs/guide/running-tasks.md index 5f237e91b0..aaccd66516 100644 --- a/website/src/next/docs/guide/running-tasks.md +++ b/website/src/next/docs/guide/running-tasks.md @@ -145,6 +145,85 @@ Task looks for these names in order of priority: - `Taskfile.dist.yaml` - `taskfile.dist.yaml` -Commit a `.dist` variant if users need to supply their own `Taskfile.yml` -override. The local file takes priority; add it to `.gitignore` if it should -remain personal. +Commit a `.dist` variant if users need to supply their own complete +`Taskfile.yml`. The local file takes priority; add it to `.gitignore` if it +should remain personal. To customize only some variables or tasks, use a local +override instead. + +## Customize a Taskfile locally {#local-overrides} + +To change a few settings for yourself without touching the shared Taskfile, +create a `Taskfile.override.yml` next to it. Task loads it on top of the main +file: + +::: code-group + +```yaml [Taskfile.yml] +version: '3' + +vars: + PORT: 3000 + +tasks: + dev: + cmds: + - echo 'PORT={{.PORT}}' +``` + +```yaml [Taskfile.override.yml] +version: '3' + +vars: + PORT: 4000 +``` + +::: + +`task dev` now prints `PORT=4000`. Add `Taskfile.override.yml` to `.gitignore` +so it stays on your machine. + +An override can set any root key of a Taskfile except `silent`, which belongs in +the [config file](../reference/config.md#silent) instead. Its `version` can't be +newer than the main Taskfile's, so `version: '3'` keeps working when the main +Taskfile bumps its version: + +- Settings such as `output`, `method`, `run`, `interval`, `set`, `shopt` and + `use_gitignore` replace the main Taskfile's when present. An empty list, like + `set: []`, clears the main one. Command-line flags such as `--output` still + take priority. +- Includes are added, for example your own tasks under + `includes: {me: ~/tasks/Taskfile.yml}`. Redefining an include of the main + Taskfile is an error; to point it elsewhere, have its path read a variable. +- Variables and environment variables are merged: yours win, the others are + kept. +- Your `dotenv` files are read before the main Taskfile's, so their values win. + Like any dotenv file, they don't override a key set under `env`; use `env` in + your override for that. +- A task with the same name replaces the original entirely. To tweak a single + value of a shared task, have the main Taskfile read it from a variable, then + override that variable. + +::: tip + +An overridden variable keeps its place in the main Taskfile, so its value can +only use variables declared above it. + +::: + +The lowercase and `.yaml` spellings (`taskfile.override.yml`, +`Taskfile.override.yaml`, `taskfile.override.yaml`) work too. Task only looks +next to the root Taskfile it finds: included, remote and stdin Taskfiles never +get an override. Since the override is read before includes, a variable can +change which file gets included, but it can't redefine an included task like +`docs:build`. + +To run without your override, name the Taskfile explicitly: + +```shell +task -t Taskfile.yml dev # prints PORT=3000 +``` + +For the same reason, a task that calls Task again with +`-t {{.ROOT_TASKFILE}}` or +`-t {{.TASKFILE}}` loses your override. Use +`task -d {{.ROOT_DIR}}` instead to keep it.