diff --git a/executor_test.go b/executor_test.go index e0ce4e2786..c2b09316d1 100644 --- a/executor_test.go +++ b/executor_test.go @@ -1289,3 +1289,36 @@ func TestIf(t *testing.T) { NewExecutorTest(t, opts...) } } + +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(), + ) + // Only the discovered root's directory is searched, so the invalid + // override in child/ is ignored. + NewExecutorTest(t, + WithName("subdirectory"), + WithExecutorOptions(task.WithDir(dir+"/child"), task.WithSilent(true)), + WithTask("dev"), + ) + 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/internal/flags/override_test.go b/internal/flags/override_test.go new file mode 100644 index 0000000000..11058378e8 --- /dev/null +++ b/internal/flags/override_test.go @@ -0,0 +1,37 @@ +package flags + +import ( + "bytes" + "os" + "path/filepath" + "runtime" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/go-task/task/v3" +) + +func TestAutomaticOverrideGlobal(t *testing.T) { //nolint:paralleltest // changes process-wide flags and home directory + originalGlobal, originalDir, originalEntrypoint, originalTimeout := Global, Dir, Entrypoint, Timeout + t.Cleanup(func() { + Global, Dir, Entrypoint, Timeout = originalGlobal, originalDir, originalEntrypoint, originalTimeout + }) + dir := t.TempDir() + homeKey := "HOME" + if runtime.GOOS == "windows" { + homeKey = "USERPROFILE" + } + t.Setenv(homeKey, dir) + require.NoError(t, os.WriteFile(filepath.Join(dir, "Taskfile.yml"), []byte("version: '3'\nvars: {PORT: 3000}\ntasks:\n dev: echo {{.PORT}}\n"), 0o600)) + require.NoError(t, os.WriteFile(filepath.Join(dir, "Taskfile.override.yml"), []byte("version: '3'\nvars: {PORT: 4000}\n"), 0o600)) + + Global, Dir, Entrypoint, Timeout = true, "", "", time.Second + var stdout bytes.Buffer + e := task.NewExecutor(WithFlags(), task.WithStdout(&stdout), task.WithStderr(&stdout), task.WithSilent(true)) + require.NoError(t, e.Setup()) + require.NoError(t, e.Run(t.Context(), &task.Call{Task: "dev"})) + assert.Equal(t, "4000\n", stdout.String()) +} 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..4554114937 --- /dev/null +++ b/taskfile/ast/override.go @@ -0,0 +1,34 @@ +package ast + +import ( + "fmt" + + "github.com/go-task/task/v3/errors" +) + +// MergeOverride applies a local override: vars and env merge by key, tasks are +// replaced whole. +func (tf *Taskfile) MergeOverride(override *Taskfile) error { + if !tf.Version.Equal(override.Version) { + return &errors.TaskfileVersionCheckError{ + URI: override.Location, + SchemaVersion: override.Version, + Message: fmt.Sprintf("must match the main Taskfile (%s)", tf.Version), + } + } + 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) + 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..b2393292f2 --- /dev/null +++ b/taskfile/override.go @@ -0,0 +1,61 @@ +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 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) + } + override, err := decodeTaskfile(b, path, true) + if 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", "vars", "env", "tasks": + 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..303daa1763 --- /dev/null +++ b/taskfile/override_test.go @@ -0,0 +1,220 @@ +package taskfile + +import ( + "fmt" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "slices" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/go-task/task/v3/errors" + "github.com/go-task/task/v3/taskfile/ast" +) + +func writeOverrideFixture(t *testing.T, dir, name, content string) string { + t.Helper() + path := filepath.Join(dir, name) + require.NoError(t, os.MkdirAll(filepath.Dir(path), 0o755)) + require.NoError(t, os.WriteFile(path, []byte(content), 0o600)) + return path +} + +func readOverrideFixture(t *testing.T, dir string, opts ...ReaderOption) (*ast.Taskfile, error) { + t.Helper() + node, err := NewFileNode("", dir) + require.NoError(t, err) + graph, err := NewReader(opts...).Read(t.Context(), node) + if err != nil { + return nil, err + } + return graph.Merge() +} + +func TestAutomaticOverrideMerge(t *testing.T) { + t.Parallel() + dir := t.TempDir() + main := writeOverrideFixture(t, dir, "Taskfile.yml", `version: '3' +vars: + PORT: 3000 + KEEP: original + CONFIG: {map: {old: old, shared: original}} +env: + FIRST: original + KEEP: original +tasks: + dev: + aliases: [old] + vars: {OLD: old} + env: {OLD: old} + deps: [old] + preconditions: [exit 1] + cmds: [exit 1] + desc: old + silent: true + keep: echo keep + empty: echo old +`) + override := writeOverrideFixture(t, dir, "Taskfile.override.yml", `version: '3.0.0' +vars: + PORT: 4000 + CONFIG: {map: {shared: local}} + NEW: local + NULL: null +env: + FIRST: local + NEW: local +tasks: + dev: + cmds: ['echo {{.PORT}}'] + added: echo added + empty: null +`) + tf, err := readOverrideFixture(t, dir, WithAutomaticOverride(true)) + require.NoError(t, err) + assert.Equal(t, main, tf.Location) + assert.Equal(t, []string{"PORT", "KEEP", "CONFIG", "NEW", "NULL"}, slices.Collect(tf.Vars.Keys())) + port, _ := tf.Vars.Get("PORT") + assert.Equal(t, 4000, port.Value) + config, _ := tf.Vars.Get("CONFIG") + assert.Equal(t, map[string]any{"shared": "local"}, config.Value) + null, ok := tf.Vars.Get("NULL") + assert.True(t, ok) + assert.Nil(t, null.Value) + assert.Equal(t, []string{"FIRST", "KEEP", "NEW"}, slices.Collect(tf.Env.Keys())) + first, _ := tf.Env.Get("FIRST") + assert.Equal(t, "local", first.Value) + keep, _ := tf.Env.Get("KEEP") + assert.Equal(t, "original", keep.Value) + assert.Equal(t, []string{"dev", "keep", "empty", "added"}, slices.Collect(tf.Tasks.Keys(nil))) + dev, _ := tf.Tasks.Get("dev") + assert.Equal(t, &ast.Task{ + Task: "dev", Cmds: []*ast.Cmd{{Cmd: "echo {{.PORT}}"}}, + Location: &ast.Location{Taskfile: override, Line: 11, Column: 3}, + }, dev) + added, _ := tf.Tasks.Get("added") + assert.Equal(t, override, added.Location.Taskfile) + unchanged, _ := tf.Tasks.Get("keep") + assert.Equal(t, main, unchanged.Location.Taskfile) + empty, ok := tf.Tasks.Get("empty") + require.True(t, ok) + assert.Empty(t, empty.Cmds) + assert.Equal(t, override, empty.Location.Taskfile) +} + +func TestAutomaticOverrideNames(t *testing.T) { + t.Parallel() + // Each suffix of the list checks both fallback and first-present priority. + // Case-insensitive filesystems only exercise the .yml/.yaml priority. + for i, name := range defaultOverrides { + t.Run(name, func(t *testing.T) { + t.Parallel() + dir := t.TempDir() + writeOverrideFixture(t, dir, "Taskfile.dist.yml", "version: '3'\n") + for j := len(defaultOverrides) - 1; j >= i; j-- { + writeOverrideFixture(t, dir, defaultOverrides[j], fmt.Sprintf("version: '3'\nvars: {WINNER: %d}\n", j)) + } + tf, err := readOverrideFixture(t, dir, WithAutomaticOverride(true)) + require.NoError(t, err) + winner, _ := tf.Vars.Get("WINNER") + assert.Equal(t, i, winner.Value) + }) + } +} + +func TestAutomaticOverrideErrors(t *testing.T) { + t.Parallel() + tests := []struct{ name, content, message string }{ + {"invalid YAML", "version: [", "yaml:"}, + {"missing version", "vars: {PORT: 4000}", "Missing schema version"}, + {"mismatched version", "version: '3.1'", "(3.1.0) must match the main Taskfile (3.0.0)"}, + {"sequence", "- version: '3'", "taskfile override"}, + } + for _, key := range []string{"includes", "unknown", "<<"} { + tests = append(tests, struct{ name, content, message string }{key, "version: '3'\n" + key + ": null", "not allowed"}) + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + dir := t.TempDir() + writeOverrideFixture(t, dir, "Taskfile.yml", "version: '3'\n") + path := writeOverrideFixture(t, dir, "Taskfile.override.yml", tt.content) + writeOverrideFixture(t, dir, "Taskfile.override.yaml", "version: '3'\n") + _, err := readOverrideFixture(t, dir, WithAutomaticOverride(true)) + require.ErrorContains(t, err, tt.message) + assert.Contains(t, err.Error(), "Taskfile.override.yml") + if decodeErr, ok := errors.AsType[*errors.TaskfileDecodeError](err); ok { + assert.Equal(t, path, decodeErr.Location) + assert.Positive(t, decodeErr.Line) + } + }) + } +} + +func TestAutomaticOverrideReadError(t *testing.T) { + t.Parallel() + dir := t.TempDir() + writeOverrideFixture(t, dir, "Taskfile.yml", "version: '3'\n") + require.NoError(t, os.Mkdir(filepath.Join(dir, "Taskfile.override.yml"), 0o755)) + writeOverrideFixture(t, dir, "Taskfile.override.yaml", "version: '3'\n") + _, err := readOverrideFixture(t, dir, WithAutomaticOverride(true)) + require.ErrorContains(t, err, "task: Failed to read Taskfile override") + assert.Contains(t, err.Error(), "Taskfile.override.yml") +} + +func TestAutomaticOverrideIncludes(t *testing.T) { + t.Parallel() + for _, conflict := range []bool{false, true} { + t.Run(fmt.Sprint(conflict), func(t *testing.T) { + t.Parallel() + dir := t.TempDir() + writeOverrideFixture(t, dir, "Taskfile.yml", "version: '3'\nvars: {INCLUDE: missing}\nincludes:\n docs: '{{.INCLUDE}}/Taskfile.yml'\n") + override := "version: '3'\nvars: {INCLUDE: docs}\n" + if conflict { + override += "tasks:\n docs:build: echo local\n" + } + writeOverrideFixture(t, dir, "Taskfile.override.yml", override) + included := writeOverrideFixture(t, dir, "docs/Taskfile.yml", "version: '3'\ntasks:\n build: echo included\n") + writeOverrideFixture(t, dir, "docs/Taskfile.override.yml", "invalid YAML: [") + tf, err := readOverrideFixture(t, dir, WithAutomaticOverride(true)) + if conflict { + var conflictErr *errors.TaskNameFlattenConflictError + require.ErrorAs(t, err, &conflictErr) + assert.Equal(t, "docs:build", conflictErr.TaskName) + } else { + require.NoError(t, err) + build, ok := tf.Tasks.Get("docs:build") + require.True(t, ok) + assert.Equal(t, included, build.Location.Taskfile) + } + }) + } +} + +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 e7a3de4912..0b39ac6e56 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 @@ -156,6 +158,11 @@ func (r *Reader) include(ctx context.Context, node Node) error { if err != nil { return err } + if _, ok := node.(*FileNode); ok && node.Parent() == nil && r.automaticOverride { + 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 @@ -256,8 +263,18 @@ func (r *Reader) readNode(ctx context.Context, node Node) (*ast.Taskfile, error) return nil, err } + return decodeTaskfile(b, node.Location(), false) +} + +func decodeTaskfile(b []byte, location string, override bool) (*ast.Taskfile, error) { var tf ast.Taskfile - if err := yaml.Unmarshal(b, &tf); err != nil { + var err error + if override { + err = yaml.Unmarshal(b, (*taskfileOverride)(&tf)) + } else { + err = yaml.Unmarshal(b, &tf) + } + if 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, @@ -265,18 +282,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 nil, taskfileDecodeErr.WithFileInfo(location, snippet.String()) } - return nil, &errors.TaskfileInvalidError{URI: filepathext.TryAbsToRel(node.Location()), Err: err} + return nil, &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 nil, &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 { diff --git a/testdata/override/Taskfile.override.yml b/testdata/override/Taskfile.override.yml new file mode 100644 index 0000000000..8fa35bf804 --- /dev/null +++ b/testdata/override/Taskfile.override.yml @@ -0,0 +1,12 @@ +version: '3' + +vars: + PORT: 4000 + +env: + TASK_OVERRIDE_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..2296ba688a --- /dev/null +++ b/testdata/override/Taskfile.yml @@ -0,0 +1,12 @@ +version: '3' + +vars: + PORT: 3000 + +env: + TASK_OVERRIDE_ENV: main + +tasks: + dev: + cmds: + - echo {{.PORT}} $TASK_OVERRIDE_ENV 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..195e1127c2 --- /dev/null +++ b/testdata/override/child/testdata/TestAutomaticOverride-subdirectory.golden @@ -0,0 +1 @@ +4000 local 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..37c18d8907 --- /dev/null +++ b/testdata/override/testdata/TestAutomaticOverride-added_task_location.golden @@ -0,0 +1 @@ +{{.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..195e1127c2 --- /dev/null +++ b/testdata/override/testdata/TestAutomaticOverride-discovered.golden @@ -0,0 +1 @@ +4000 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..195e1127c2 --- /dev/null +++ b/testdata/override/testdata/TestAutomaticOverride-explicit_directory.golden @@ -0,0 +1 @@ +4000 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..7bab9bc24c --- /dev/null +++ b/testdata/override/testdata/TestAutomaticOverride-explicit_file.golden @@ -0,0 +1 @@ +3000 main diff --git a/website/src/next/docs/guide/running-tasks.md b/website/src/next/docs/guide/running-tasks.md index 5f237e91b0..b3316de79c 100644 --- a/website/src/next/docs/guide/running-tasks.md +++ b/website/src/next/docs/guide/running-tasks.md @@ -145,6 +145,70 @@ 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 `vars`, `env` and `tasks`, and its `version` must match the +main Taskfile's: + +- Variables and environment variables are merged: yours win, the others are + kept. +- 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. + +Anything else at the root, such as `includes` or `dotenv`, is rejected. + +::: 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 +```