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.