From 852633838d7359fdef9970985418229a3f90f801 Mon Sep 17 00:00:00 2001 From: Valentin Maerten Date: Sun, 27 Sep 2026 10:38:57 +0200 Subject: [PATCH 01/19] feat: load local Taskfile overrides automatically --- executor.go | 1 + internal/flags/override_test.go | 51 ++++ override_test.go | 81 ++++++ setup.go | 2 + taskfile/ast/override.go | 28 ++ taskfile/options.go | 15 ++ taskfile/override.go | 60 +++++ taskfile/override_test.go | 256 +++++++++++++++++++ taskfile/reader.go | 26 +- website/src/next/docs/guide/running-tasks.md | 89 ++++++- 10 files changed, 601 insertions(+), 8 deletions(-) create mode 100644 internal/flags/override_test.go create mode 100644 override_test.go create mode 100644 taskfile/ast/override.go create mode 100644 taskfile/override.go create mode 100644 taskfile/override_test.go diff --git a/executor.go b/executor.go index 80f46b6374..09c1f86410 100644 --- a/executor.go +++ b/executor.go @@ -78,6 +78,7 @@ type ( fuzzyModelOnce sync.Once promptedVars *ast.Vars // vars collected via interactive prompts + automaticOverride bool concurrencySemaphore chan struct{} taskCallCount map[string]*int32 mkdirMutexMap map[string]*sync.Mutex diff --git a/internal/flags/override_test.go b/internal/flags/override_test.go new file mode 100644 index 0000000000..c45ed8354d --- /dev/null +++ b/internal/flags/override_test.go @@ -0,0 +1,51 @@ +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 TestAutomaticOverrideFlags(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)) + for _, tt := range []struct { //nolint:paralleltest // subtests change process-wide flags + name string + global bool + entrypoint, want string + }{ + {name: "dir", want: "4000\n"}, + {name: "global", global: true, want: "4000\n"}, + {name: "global explicit", global: true, entrypoint: filepath.Join(dir, "Taskfile.yml"), want: "3000\n"}, + } { + t.Run(tt.name, func(t *testing.T) { + Global, Dir, Entrypoint, Timeout = tt.global, dir, tt.entrypoint, time.Second + if tt.global { + Dir = "" + } + 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, tt.want, stdout.String()) + }) + } +} diff --git a/override_test.go b/override_test.go new file mode 100644 index 0000000000..8f95edbbde --- /dev/null +++ b/override_test.go @@ -0,0 +1,81 @@ +package task_test + +import ( + "bytes" + "fmt" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/go-task/task/v3" +) + +func TestAutomaticOverrideExecution(t *testing.T) { + t.Parallel() + for _, tt := range []struct { + name, main string + explicit, subdir, absent bool + }{ + {name: "automatic", main: "Taskfile.yml"}, + {name: "dist", main: "Taskfile.dist.yml"}, + {name: "subdirectory", main: "Taskfile.yml", subdir: true}, + {name: "explicit", main: "Taskfile.yml", explicit: true}, + {name: "absent", main: "Taskfile.yml", absent: true}, + } { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + dir := t.TempDir() + main := filepath.Join(dir, tt.main) + require.NoError(t, os.WriteFile(main, []byte(`version: '3' +vars: + PORT: 3000 +env: + TASK_OVERRIDE_ENV: main +tasks: + dev: + cmds: ['echo {{.PORT}} $TASK_OVERRIDE_ENV'] + origin: + cmds: ['echo {{.TASKFILE}} {{.ROOT_TASKFILE}}'] +`), 0o600)) + override := filepath.Join(dir, "Taskfile.override.yml") + if !tt.absent { + require.NoError(t, os.WriteFile(override, []byte(`version: '3' +vars: + PORT: 4000 +env: + TASK_OVERRIDE_ENV: local +tasks: + added: + cmds: ['echo {{.TASKFILE}} {{.ROOT_TASKFILE}}'] +`), 0o600)) + } + workdir := dir + if tt.subdir { + workdir = filepath.Join(dir, "child") + require.NoError(t, os.Mkdir(workdir, 0o755)) + // Only the discovered root's directory is searched. + require.NoError(t, os.WriteFile(filepath.Join(workdir, "Taskfile.override.yml"), []byte("bad: ["), 0o600)) + } + var stdout bytes.Buffer + e := task.NewExecutor(task.WithDir(workdir), task.WithStdout(&stdout), task.WithStderr(&stdout), task.WithSilent(true)) + if tt.explicit { + e.Entrypoint = main + } + require.NoError(t, e.Setup()) + require.NoError(t, e.Run(t.Context(), &task.Call{Task: "dev"})) + if tt.explicit || tt.absent { + assert.Equal(t, "3000 main\n", stdout.String()) + } else { + assert.Equal(t, "4000 local\n", stdout.String()) + stdout.Reset() + require.NoError(t, e.Run(t.Context(), &task.Call{Task: "added"}, &task.Call{Task: "origin"})) + assert.Equal(t, fmt.Sprintf("%s %s\n%s %s\n", filepath.ToSlash(override), filepath.ToSlash(main), filepath.ToSlash(main), filepath.ToSlash(main)), stdout.String()) + } + assert.Equal(t, main, e.Entrypoint) + assert.Equal(t, main, e.Taskfile.Location) + }) + } +} diff --git a/setup.go b/setup.go index 17c01ed12f..8426b24057 100644 --- a/setup.go +++ b/setup.go @@ -54,6 +54,7 @@ func (e *Executor) Setup() error { } func (e *Executor) getRootNode() (taskfile.Node, error) { + e.automaticOverride = e.Entrypoint == "" node, err := taskfile.NewRootNode(e.Entrypoint, e.Dir, e.Insecure, e.Timeout, taskfile.WithCACert(e.CACert), taskfile.WithCert(e.Cert), @@ -82,6 +83,7 @@ func (e *Executor) readTaskfile(node taskfile.Node) error { return e.Logger.Prompt(logger.Yellow, s, "n", "y", "yes") } reader := taskfile.NewReader( + taskfile.WithAutomaticOverride(e.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..bba7586fdf --- /dev/null +++ b/taskfile/ast/override.go @@ -0,0 +1,28 @@ +package ast + +import "fmt" + +// MergeOverride merges local variables and environment entries by key and +// replaces entire tasks. Existing keys keep their order; new keys are appended +// in declaration order. The main Taskfile's identity and settings are preserved. +func (tf *Taskfile) MergeOverride(override *Taskfile) error { + if !tf.Version.Equal(override.Version) { + return fmt.Errorf("task: Taskfile override %q has version %s, but %q has version %s", + override.Location, override.Version, tf.Location, 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.DeepCopy()) + } + return nil +} diff --git a/taskfile/options.go b/taskfile/options.go index badf814c29..1651c7987e 100644 --- a/taskfile/options.go +++ b/taskfile/options.go @@ -2,6 +2,21 @@ package taskfile import "time" +// WithAutomaticOverride enables loading a local override beside the root +// Taskfile. It is disabled by default and does not apply to remote Taskfiles, +// stdin, or included Taskfiles. +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..88370861a6 --- /dev/null +++ b/taskfile/override.go @@ -0,0 +1,60 @@ +package taskfile + +import ( + "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) readAutomaticOverride(node *FileNode, tf *ast.Taskfile) error { + for _, name := range defaultOverrides { + path := filepath.Join(filepath.Dir(node.Location()), 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 err + } + b, err := os.ReadFile(path) + if err != nil { + return 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..da45eb0cb4 --- /dev/null +++ b/taskfile/override_test.go @@ -0,0 +1,256 @@ +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. + 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)) + } + // Case-insensitive filesystems cannot store both capitalization variants. + distinct := true + for j := i + 1; j < len(defaultOverrides); j++ { + a, err := os.Stat(filepath.Join(dir, name)) + require.NoError(t, err) + b, err := os.Stat(filepath.Join(dir, defaultOverrides[j])) + require.NoError(t, err) + if os.SameFile(a, b) { + distinct = false + } + } + tf, err := readOverrideFixture(t, dir, WithAutomaticOverride(true)) + require.NoError(t, err) + winner, _ := tf.Vars.Get("WINNER") + assert.Equal(t, i, winner.Value) + if !distinct { + t.Log("Capitalization priority requires a case-sensitive filesystem") + } + }) + } +} + +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"}, + {"empty", "", "Missing schema version"}, + {"null", "null", "Missing schema version"}, + {"mismatched version", "version: '3.1'", "has version 3.1.0"}, + {"invalid version", "version: bad", "invalid semantic version"}, + {"invalid vars", "version: '3'\nvars: []", "vars"}, + {"invalid task", "version: '3'\ntasks:\n dev:\n cmds: {}", "cannot unmarshal"}, + {"sequence", "- version: '3'", "taskfile override"}, + {"duplicate version", "version: '3'\nversion: '3'", "already defined"}, + } + for _, key := range []string{"includes", "dotenv", "output", "method", "silent", "run", "interval", "set", "shopt", "use_gitignore", "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, "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 TestAutomaticOverrideReaderDefault(t *testing.T) { + t.Parallel() + dir := t.TempDir() + writeOverrideFixture(t, dir, "Taskfile.yml", "version: '3'\n") + writeOverrideFixture(t, dir, "Taskfile.override.yml", "invalid YAML: [") + _, err := readOverrideFixture(t, dir) + require.NoError(t, err) +} + +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) +} + +func TestAutomaticOverrideAlone(t *testing.T) { + t.Parallel() + dir := t.TempDir() + writeOverrideFixture(t, dir, "Taskfile.override.yml", "version: '3'\ntasks: {dev: echo local}\n") + _, err := NewFileNode("", dir) + var notFound errors.TaskfileNotFoundError + require.ErrorAs(t, err, ¬Found) +} diff --git a/taskfile/reader.go b/taskfile/reader.go index d84abc80e1..af5de6e421 100644 --- a/taskfile/reader.go +++ b/taskfile/reader.go @@ -42,6 +42,7 @@ type ( // [ast.TaskfileGraph] from them. Reader struct { graph *ast.TaskfileGraph + automaticOverride bool insecure bool download bool offline bool @@ -158,6 +159,11 @@ func (r *Reader) include(ctx context.Context, node Node) error { if err != nil { return err } + if local, ok := node.(*FileNode); ok && node.Parent() == nil && r.automaticOverride { + if err := r.readAutomaticOverride(local, vertex.Taskfile); err != nil { + return err + } + } // Create an error group to wait for all included Taskfiles to be read var g errgroup.Group @@ -258,8 +264,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, @@ -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 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/website/src/next/docs/guide/running-tasks.md b/website/src/next/docs/guide/running-tasks.md index 5f237e91b0..33bb93041e 100644 --- a/website/src/next/docs/guide/running-tasks.md +++ b/website/src/next/docs/guide/running-tasks.md @@ -145,6 +145,89 @@ 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} + +When Task discovers the root Taskfile automatically, it also loads an optional +`Taskfile.override.yml` from the same directory. Keep the shared configuration +in the main file and put personal changes in the override: + +::: 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` prints `PORT=4000`. Add `Taskfile.override.yml` to your project's +`.gitignore` to keep this configuration local. + +The override accepts only these root fields: + +- `version` is required and must match the main Taskfile's schema version. + Equivalent versions such as `'3'` and `'3.0.0'` match. +- `vars` and `env` merge by key, with override values taking priority over the + main file's values. Structured values, including maps, are replaced entirely. +- `tasks` adds new tasks and replaces existing task definitions entirely. A + replacement does not retain the original task's variables, aliases, + dependencies, preconditions or other settings. + +Other root fields, including `includes` and `dotenv`, cause an error. There is +no deletion syntax: empty and `null` values use the usual Taskfile decoding +rules. + +Task searches for the following override names in order and loads only the +first one present: + +1. `Taskfile.override.yml` +2. `taskfile.override.yml` +3. `Taskfile.override.yaml` +4. `taskfile.override.yaml` + +An absent override is ignored. An unreadable or invalid override causes an +error; Task does not try the next name. An override alone cannot serve as an +automatically discovered root Taskfile. + +This also works with `.dist` Taskfiles, when running from a subdirectory, with +`--dir`, and with `--global`. Only the directory containing the discovered root +Taskfile is searched. Included Taskfiles, remote roots and Taskfiles read from +stdin do not load automatic overrides. + +To ignore the override, select the main file explicitly: + +```shell +task -t Taskfile.yml dev +``` + +With the example above, this prints `PORT=3000`. + +Overrides are applied before resolving include paths, so an overridden variable +can change which file is included. The usual inclusion rules still apply: +defining `docs:build` in an override conflicts with an included task of that +name. Overrides do not change the precedence rules for included variables, +task variables, command-line arguments or the process environment. + +The main Taskfile remains `ROOT_TASKFILE`. For tasks added or replaced by the +override, `TASKFILE` and diagnostic locations point to the override file. +Overrides are loaded at startup; this does not add Taskfile reloading to +[watch mode](./watch.md). From 2204c3439d233620d0d08484aa4d18a624399c1e Mon Sep 17 00:00:00 2001 From: Valentin Maerten Date: Sat, 24 Oct 2026 15:58:51 +0200 Subject: [PATCH 02/19] refactor(override): pass the automatic flag through setup instead of the executor --- executor.go | 1 - setup.go | 8 ++++---- taskfile/ast/override.go | 7 +++---- taskfile/options.go | 5 ++--- taskfile/override.go | 4 ++-- taskfile/reader.go | 5 +++-- 6 files changed, 14 insertions(+), 16 deletions(-) diff --git a/executor.go b/executor.go index 09c1f86410..80f46b6374 100644 --- a/executor.go +++ b/executor.go @@ -78,7 +78,6 @@ type ( fuzzyModelOnce sync.Once promptedVars *ast.Vars // vars collected via interactive prompts - automaticOverride bool concurrencySemaphore chan struct{} taskCallCount map[string]*int32 mkdirMutexMap map[string]*sync.Mutex diff --git a/setup.go b/setup.go index 8426b24057..27776475a3 100644 --- a/setup.go +++ b/setup.go @@ -25,6 +25,7 @@ import ( func (e *Executor) Setup() error { e.setupLogger() + automaticOverride := 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() @@ -54,7 +55,6 @@ func (e *Executor) Setup() error { } func (e *Executor) getRootNode() (taskfile.Node, error) { - e.automaticOverride = e.Entrypoint == "" node, err := taskfile.NewRootNode(e.Entrypoint, e.Dir, e.Insecure, e.Timeout, taskfile.WithCACert(e.CACert), taskfile.WithCert(e.Cert), @@ -73,7 +73,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) { @@ -83,7 +83,7 @@ func (e *Executor) readTaskfile(node taskfile.Node) error { return e.Logger.Prompt(logger.Yellow, s, "n", "y", "yes") } reader := taskfile.NewReader( - taskfile.WithAutomaticOverride(e.automaticOverride), + 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 index bba7586fdf..4a265ff10c 100644 --- a/taskfile/ast/override.go +++ b/taskfile/ast/override.go @@ -2,9 +2,8 @@ package ast import "fmt" -// MergeOverride merges local variables and environment entries by key and -// replaces entire tasks. Existing keys keep their order; new keys are appended -// in declaration order. The main Taskfile's identity and settings are preserved. +// 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 fmt.Errorf("task: Taskfile override %q has version %s, but %q has version %s", @@ -22,7 +21,7 @@ func (tf *Taskfile) MergeOverride(override *Taskfile) error { 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.DeepCopy()) + tf.Tasks.Set(name, task) } return nil } diff --git a/taskfile/options.go b/taskfile/options.go index 1651c7987e..0d6b2316e6 100644 --- a/taskfile/options.go +++ b/taskfile/options.go @@ -2,9 +2,8 @@ package taskfile import "time" -// WithAutomaticOverride enables loading a local override beside the root -// Taskfile. It is disabled by default and does not apply to remote Taskfiles, -// stdin, or included Taskfiles. +// 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} } diff --git a/taskfile/override.go b/taskfile/override.go index 88370861a6..0b5889db6a 100644 --- a/taskfile/override.go +++ b/taskfile/override.go @@ -17,9 +17,9 @@ var defaultOverrides = []string{ "taskfile.override.yaml", } -func (r *Reader) readAutomaticOverride(node *FileNode, tf *ast.Taskfile) error { +func readAutomaticOverride(dir string, tf *ast.Taskfile) error { for _, name := range defaultOverrides { - path := filepath.Join(filepath.Dir(node.Location()), name) + 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 diff --git a/taskfile/reader.go b/taskfile/reader.go index af5de6e421..5080c665ba 100644 --- a/taskfile/reader.go +++ b/taskfile/reader.go @@ -5,6 +5,7 @@ import ( "fmt" "net/url" "os" + "path/filepath" "slices" "sync" "time" @@ -159,8 +160,8 @@ func (r *Reader) include(ctx context.Context, node Node) error { if err != nil { return err } - if local, ok := node.(*FileNode); ok && node.Parent() == nil && r.automaticOverride { - if err := r.readAutomaticOverride(local, vertex.Taskfile); err != nil { + if _, ok := node.(*FileNode); ok && node.Parent() == nil && r.automaticOverride { + if err := readAutomaticOverride(filepath.Dir(node.Location()), vertex.Taskfile); err != nil { return err } } From 1c2bc7ae505e9369374a727daff9669c92ef89c2 Mon Sep 17 00:00:00 2001 From: Valentin Maerten Date: Sat, 24 Oct 2026 16:00:32 +0200 Subject: [PATCH 03/19] test(override): move integration tests to golden fixtures and drop redundant cases --- executor_test.go | 28 +++++++ internal/flags/override_test.go | 30 ++----- override_test.go | 81 ------------------- taskfile/override_test.go | 41 +--------- testdata/override/Taskfile.override.yml | 12 +++ testdata/override/Taskfile.yml | 12 +++ testdata/override/child/Taskfile.override.yml | 1 + .../TestAutomaticOverride-subdirectory.golden | 1 + ...tomaticOverride-added_task_location.golden | 1 + .../TestAutomaticOverride-discovered.golden | 1 + ...TestAutomaticOverride-explicit_file.golden | 1 + 11 files changed, 67 insertions(+), 142 deletions(-) delete mode 100644 override_test.go create mode 100644 testdata/override/Taskfile.override.yml create mode 100644 testdata/override/Taskfile.yml create mode 100644 testdata/override/child/Taskfile.override.yml create mode 100644 testdata/override/child/testdata/TestAutomaticOverride-subdirectory.golden create mode 100644 testdata/override/testdata/TestAutomaticOverride-added_task_location.golden create mode 100644 testdata/override/testdata/TestAutomaticOverride-discovered.golden create mode 100644 testdata/override/testdata/TestAutomaticOverride-explicit_file.golden diff --git a/executor_test.go b/executor_test.go index 6533e19c08..ca2815f860 100644 --- a/executor_test.go +++ b/executor_test.go @@ -1325,3 +1325,31 @@ 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"), + ) +} diff --git a/internal/flags/override_test.go b/internal/flags/override_test.go index c45ed8354d..11058378e8 100644 --- a/internal/flags/override_test.go +++ b/internal/flags/override_test.go @@ -14,7 +14,7 @@ import ( "github.com/go-task/task/v3" ) -func TestAutomaticOverrideFlags(t *testing.T) { //nolint:paralleltest // changes process-wide flags and home directory +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 @@ -27,25 +27,11 @@ func TestAutomaticOverrideFlags(t *testing.T) { //nolint:paralleltest // changes 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)) - for _, tt := range []struct { //nolint:paralleltest // subtests change process-wide flags - name string - global bool - entrypoint, want string - }{ - {name: "dir", want: "4000\n"}, - {name: "global", global: true, want: "4000\n"}, - {name: "global explicit", global: true, entrypoint: filepath.Join(dir, "Taskfile.yml"), want: "3000\n"}, - } { - t.Run(tt.name, func(t *testing.T) { - Global, Dir, Entrypoint, Timeout = tt.global, dir, tt.entrypoint, time.Second - if tt.global { - Dir = "" - } - 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, tt.want, stdout.String()) - }) - } + + 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/override_test.go b/override_test.go deleted file mode 100644 index 8f95edbbde..0000000000 --- a/override_test.go +++ /dev/null @@ -1,81 +0,0 @@ -package task_test - -import ( - "bytes" - "fmt" - "os" - "path/filepath" - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - - "github.com/go-task/task/v3" -) - -func TestAutomaticOverrideExecution(t *testing.T) { - t.Parallel() - for _, tt := range []struct { - name, main string - explicit, subdir, absent bool - }{ - {name: "automatic", main: "Taskfile.yml"}, - {name: "dist", main: "Taskfile.dist.yml"}, - {name: "subdirectory", main: "Taskfile.yml", subdir: true}, - {name: "explicit", main: "Taskfile.yml", explicit: true}, - {name: "absent", main: "Taskfile.yml", absent: true}, - } { - t.Run(tt.name, func(t *testing.T) { - t.Parallel() - dir := t.TempDir() - main := filepath.Join(dir, tt.main) - require.NoError(t, os.WriteFile(main, []byte(`version: '3' -vars: - PORT: 3000 -env: - TASK_OVERRIDE_ENV: main -tasks: - dev: - cmds: ['echo {{.PORT}} $TASK_OVERRIDE_ENV'] - origin: - cmds: ['echo {{.TASKFILE}} {{.ROOT_TASKFILE}}'] -`), 0o600)) - override := filepath.Join(dir, "Taskfile.override.yml") - if !tt.absent { - require.NoError(t, os.WriteFile(override, []byte(`version: '3' -vars: - PORT: 4000 -env: - TASK_OVERRIDE_ENV: local -tasks: - added: - cmds: ['echo {{.TASKFILE}} {{.ROOT_TASKFILE}}'] -`), 0o600)) - } - workdir := dir - if tt.subdir { - workdir = filepath.Join(dir, "child") - require.NoError(t, os.Mkdir(workdir, 0o755)) - // Only the discovered root's directory is searched. - require.NoError(t, os.WriteFile(filepath.Join(workdir, "Taskfile.override.yml"), []byte("bad: ["), 0o600)) - } - var stdout bytes.Buffer - e := task.NewExecutor(task.WithDir(workdir), task.WithStdout(&stdout), task.WithStderr(&stdout), task.WithSilent(true)) - if tt.explicit { - e.Entrypoint = main - } - require.NoError(t, e.Setup()) - require.NoError(t, e.Run(t.Context(), &task.Call{Task: "dev"})) - if tt.explicit || tt.absent { - assert.Equal(t, "3000 main\n", stdout.String()) - } else { - assert.Equal(t, "4000 local\n", stdout.String()) - stdout.Reset() - require.NoError(t, e.Run(t.Context(), &task.Call{Task: "added"}, &task.Call{Task: "origin"})) - assert.Equal(t, fmt.Sprintf("%s %s\n%s %s\n", filepath.ToSlash(override), filepath.ToSlash(main), filepath.ToSlash(main), filepath.ToSlash(main)), stdout.String()) - } - assert.Equal(t, main, e.Entrypoint) - assert.Equal(t, main, e.Taskfile.Location) - }) - } -} diff --git a/taskfile/override_test.go b/taskfile/override_test.go index da45eb0cb4..e463a9236f 100644 --- a/taskfile/override_test.go +++ b/taskfile/override_test.go @@ -109,6 +109,7 @@ tasks: 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() @@ -117,24 +118,10 @@ func TestAutomaticOverrideNames(t *testing.T) { for j := len(defaultOverrides) - 1; j >= i; j-- { writeOverrideFixture(t, dir, defaultOverrides[j], fmt.Sprintf("version: '3'\nvars: {WINNER: %d}\n", j)) } - // Case-insensitive filesystems cannot store both capitalization variants. - distinct := true - for j := i + 1; j < len(defaultOverrides); j++ { - a, err := os.Stat(filepath.Join(dir, name)) - require.NoError(t, err) - b, err := os.Stat(filepath.Join(dir, defaultOverrides[j])) - require.NoError(t, err) - if os.SameFile(a, b) { - distinct = false - } - } tf, err := readOverrideFixture(t, dir, WithAutomaticOverride(true)) require.NoError(t, err) winner, _ := tf.Vars.Get("WINNER") assert.Equal(t, i, winner.Value) - if !distinct { - t.Log("Capitalization priority requires a case-sensitive filesystem") - } }) } } @@ -144,16 +131,10 @@ func TestAutomaticOverrideErrors(t *testing.T) { tests := []struct{ name, content, message string }{ {"invalid YAML", "version: [", "yaml:"}, {"missing version", "vars: {PORT: 4000}", "Missing schema version"}, - {"empty", "", "Missing schema version"}, - {"null", "null", "Missing schema version"}, {"mismatched version", "version: '3.1'", "has version 3.1.0"}, - {"invalid version", "version: bad", "invalid semantic version"}, - {"invalid vars", "version: '3'\nvars: []", "vars"}, - {"invalid task", "version: '3'\ntasks:\n dev:\n cmds: {}", "cannot unmarshal"}, {"sequence", "- version: '3'", "taskfile override"}, - {"duplicate version", "version: '3'\nversion: '3'", "already defined"}, } - for _, key := range []string{"includes", "dotenv", "output", "method", "silent", "run", "interval", "set", "shopt", "use_gitignore", "unknown", "<<"} { + 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 { @@ -213,15 +194,6 @@ func TestAutomaticOverrideIncludes(t *testing.T) { } } -func TestAutomaticOverrideReaderDefault(t *testing.T) { - t.Parallel() - dir := t.TempDir() - writeOverrideFixture(t, dir, "Taskfile.yml", "version: '3'\n") - writeOverrideFixture(t, dir, "Taskfile.override.yml", "invalid YAML: [") - _, err := readOverrideFixture(t, dir) - require.NoError(t, err) -} - func TestAutomaticOverrideRemote(t *testing.T) { t.Parallel() dir := t.TempDir() @@ -245,12 +217,3 @@ func TestAutomaticOverrideStdin(t *testing.T) { //nolint:paralleltest // replace _, err = NewReader(WithAutomaticOverride(true)).Read(t.Context(), node) require.NoError(t, err) } - -func TestAutomaticOverrideAlone(t *testing.T) { - t.Parallel() - dir := t.TempDir() - writeOverrideFixture(t, dir, "Taskfile.override.yml", "version: '3'\ntasks: {dev: echo local}\n") - _, err := NewFileNode("", dir) - var notFound errors.TaskfileNotFoundError - require.ErrorAs(t, err, ¬Found) -} 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_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 From ee2206827e91de760911162223a59c0083063e49 Mon Sep 17 00:00:00 2001 From: Valentin Maerten Date: Sat, 24 Oct 2026 16:00:47 +0200 Subject: [PATCH 04/19] fix(override): load the override when the entrypoint is a directory --- executor_test.go | 5 +++++ setup.go | 12 +++++++++++- .../TestAutomaticOverride-explicit_directory.golden | 1 + 3 files changed, 17 insertions(+), 1 deletion(-) create mode 100644 testdata/override/testdata/TestAutomaticOverride-explicit_directory.golden diff --git a/executor_test.go b/executor_test.go index ca2815f860..fc87213a10 100644 --- a/executor_test.go +++ b/executor_test.go @@ -1352,4 +1352,9 @@ func TestAutomaticOverride(t *testing.T) { 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 27776475a3..3a4a85158b 100644 --- a/setup.go +++ b/setup.go @@ -25,7 +25,7 @@ import ( func (e *Executor) Setup() error { e.setupLogger() - automaticOverride := e.Entrypoint == "" + automaticOverride := discoversTaskfile(e.Entrypoint) node, err := e.getRootNode() if err != nil { return err @@ -54,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), 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 From 848f37e2eb36bd51c52b302d7492c26fe0c3a086 Mon Sep 17 00:00:00 2001 From: Valentin Maerten Date: Sat, 24 Oct 2026 16:01:29 +0200 Subject: [PATCH 05/19] fix(override): report override read and version errors with context --- taskfile/ast/override.go | 13 ++++++++++--- taskfile/override.go | 5 +++-- taskfile/override_test.go | 5 +++-- 3 files changed, 16 insertions(+), 7 deletions(-) diff --git a/taskfile/ast/override.go b/taskfile/ast/override.go index 4a265ff10c..4554114937 100644 --- a/taskfile/ast/override.go +++ b/taskfile/ast/override.go @@ -1,13 +1,20 @@ package ast -import "fmt" +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 fmt.Errorf("task: Taskfile override %q has version %s, but %q has version %s", - override.Location, override.Version, tf.Location, tf.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() diff --git a/taskfile/override.go b/taskfile/override.go index 0b5889db6a..b2393292f2 100644 --- a/taskfile/override.go +++ b/taskfile/override.go @@ -1,6 +1,7 @@ package taskfile import ( + "fmt" "os" "path/filepath" @@ -24,11 +25,11 @@ func readAutomaticOverride(dir string, tf *ast.Taskfile) error { if _, err := os.Lstat(path); errors.Is(err, os.ErrNotExist) { continue } else if err != nil { - return err + return fmt.Errorf("task: Failed to read Taskfile override: %w", err) } b, err := os.ReadFile(path) if err != nil { - return err + return fmt.Errorf("task: Failed to read Taskfile override: %w", err) } override, err := decodeTaskfile(b, path, true) if err != nil { diff --git a/taskfile/override_test.go b/taskfile/override_test.go index e463a9236f..303daa1763 100644 --- a/taskfile/override_test.go +++ b/taskfile/override_test.go @@ -131,7 +131,7 @@ func TestAutomaticOverrideErrors(t *testing.T) { tests := []struct{ name, content, message string }{ {"invalid YAML", "version: [", "yaml:"}, {"missing version", "vars: {PORT: 4000}", "Missing schema version"}, - {"mismatched version", "version: '3.1'", "has version 3.1.0"}, + {"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", "<<"} { @@ -162,7 +162,8 @@ func TestAutomaticOverrideReadError(t *testing.T) { 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, "Taskfile.override.yml") + require.ErrorContains(t, err, "task: Failed to read Taskfile override") + assert.Contains(t, err.Error(), "Taskfile.override.yml") } func TestAutomaticOverrideIncludes(t *testing.T) { From 08cef4f67c5af5a628eec9dcb1595241cb002879 Mon Sep 17 00:00:00 2001 From: Valentin Maerten Date: Sat, 24 Oct 2026 16:01:55 +0200 Subject: [PATCH 06/19] docs(override): shorten the local override section and document its limits --- website/src/next/docs/guide/running-tasks.md | 61 ++++++-------------- 1 file changed, 17 insertions(+), 44 deletions(-) diff --git a/website/src/next/docs/guide/running-tasks.md b/website/src/next/docs/guide/running-tasks.md index 33bb93041e..675d79493b 100644 --- a/website/src/next/docs/guide/running-tasks.md +++ b/website/src/next/docs/guide/running-tasks.md @@ -179,55 +179,28 @@ vars: ::: -`task dev` prints `PORT=4000`. Add `Taskfile.override.yml` to your project's -`.gitignore` to keep this configuration local. +`task dev` prints `PORT=4000`. Add `Taskfile.override.yml` to `.gitignore` to +keep it personal. -The override accepts only these root fields: +Besides `version`, which must match the main Taskfile, an override accepts: -- `version` is required and must match the main Taskfile's schema version. - Equivalent versions such as `'3'` and `'3.0.0'` match. -- `vars` and `env` merge by key, with override values taking priority over the - main file's values. Structured values, including maps, are replaced entirely. -- `tasks` adds new tasks and replaces existing task definitions entirely. A - replacement does not retain the original task's variables, aliases, - dependencies, preconditions or other settings. +- `vars` and `env`, merged by key with the override winning. An overridden + variable keeps its position in the main Taskfile, so it can only reference + variables declared before it. +- `tasks`, which adds new tasks or replaces existing ones whole: a replaced task + keeps nothing from the original. To change a single value of a task, move it + into a variable in the main Taskfile and override that variable. -Other root fields, including `includes` and `dotenv`, cause an error. There is -no deletion syntax: empty and `null` values use the usual Taskfile decoding -rules. +Any other root key, such as `includes` or `dotenv`, is an error. -Task searches for the following override names in order and loads only the -first one present: +`taskfile.override.yml`, `Taskfile.override.yaml` and `taskfile.override.yaml` +are also recognized; only the first one present is loaded. Overrides apply +before includes are resolved, so a variable can change which file is included, +but an override cannot redefine an included task such as `docs:build`. +Included, remote and stdin Taskfiles never load an override. -1. `Taskfile.override.yml` -2. `taskfile.override.yml` -3. `Taskfile.override.yaml` -4. `taskfile.override.yaml` - -An absent override is ignored. An unreadable or invalid override causes an -error; Task does not try the next name. An override alone cannot serve as an -automatically discovered root Taskfile. - -This also works with `.dist` Taskfiles, when running from a subdirectory, with -`--dir`, and with `--global`. Only the directory containing the discovered root -Taskfile is searched. Included Taskfiles, remote roots and Taskfiles read from -stdin do not load automatic overrides. - -To ignore the override, select the main file explicitly: +Naming the file with `--taskfile` skips the override: ```shell -task -t Taskfile.yml dev +task -t Taskfile.yml dev # prints PORT=3000 ``` - -With the example above, this prints `PORT=3000`. - -Overrides are applied before resolving include paths, so an overridden variable -can change which file is included. The usual inclusion rules still apply: -defining `docs:build` in an override conflicts with an included task of that -name. Overrides do not change the precedence rules for included variables, -task variables, command-line arguments or the process environment. - -The main Taskfile remains `ROOT_TASKFILE`. For tasks added or replaced by the -override, `TASKFILE` and diagnostic locations point to the override file. -Overrides are loaded at startup; this does not add Taskfile reloading to -[watch mode](./watch.md). From d433caccefb6e03cdaa90f8a3be9e7f014dde608 Mon Sep 17 00:00:00 2001 From: Valentin Maerten Date: Sat, 24 Oct 2026 16:08:03 +0200 Subject: [PATCH 07/19] docs(override): rephrase the local override section --- website/src/next/docs/guide/running-tasks.md | 46 ++++++++++++-------- 1 file changed, 27 insertions(+), 19 deletions(-) diff --git a/website/src/next/docs/guide/running-tasks.md b/website/src/next/docs/guide/running-tasks.md index 675d79493b..b3316de79c 100644 --- a/website/src/next/docs/guide/running-tasks.md +++ b/website/src/next/docs/guide/running-tasks.md @@ -152,9 +152,9 @@ override instead. ## Customize a Taskfile locally {#local-overrides} -When Task discovers the root Taskfile automatically, it also loads an optional -`Taskfile.override.yml` from the same directory. Keep the shared configuration -in the main file and put personal changes in the override: +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 @@ -179,27 +179,35 @@ vars: ::: -`task dev` prints `PORT=4000`. Add `Taskfile.override.yml` to `.gitignore` to -keep it personal. +`task dev` now prints `PORT=4000`. Add `Taskfile.override.yml` to `.gitignore` +so it stays on your machine. -Besides `version`, which must match the main Taskfile, an override accepts: +An override can set `vars`, `env` and `tasks`, and its `version` must match the +main Taskfile's: -- `vars` and `env`, merged by key with the override winning. An overridden - variable keeps its position in the main Taskfile, so it can only reference - variables declared before it. -- `tasks`, which adds new tasks or replaces existing ones whole: a replaced task - keeps nothing from the original. To change a single value of a task, move it - into a variable in the main Taskfile and override that variable. +- 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. -Any other root key, such as `includes` or `dotenv`, is an error. +Anything else at the root, such as `includes` or `dotenv`, is rejected. -`taskfile.override.yml`, `Taskfile.override.yaml` and `taskfile.override.yaml` -are also recognized; only the first one present is loaded. Overrides apply -before includes are resolved, so a variable can change which file is included, -but an override cannot redefine an included task such as `docs:build`. -Included, remote and stdin Taskfiles never load an override. +::: tip -Naming the file with `--taskfile` skips the override: +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 From 8935e77bdb94f07a2606442e207a91d945d60e21 Mon Sep 17 00:00:00 2001 From: Valentin Maerten Date: Sun, 1 Nov 2026 14:18:26 +0100 Subject: [PATCH 08/19] test(override): drop the --global test, covered by the discovered case --- internal/flags/override_test.go | 37 --------------------------------- 1 file changed, 37 deletions(-) delete mode 100644 internal/flags/override_test.go diff --git a/internal/flags/override_test.go b/internal/flags/override_test.go deleted file mode 100644 index 11058378e8..0000000000 --- a/internal/flags/override_test.go +++ /dev/null @@ -1,37 +0,0 @@ -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()) -} From 69106698aea8963bb28bde01613cc539555fa380 Mon Sep 17 00:00:00 2001 From: Valentin Maerten Date: Sun, 1 Nov 2026 14:19:22 +0100 Subject: [PATCH 09/19] refactor(override): pass the decode target instead of a flag and extract loadsOverride --- taskfile/override.go | 11 ++++++++--- taskfile/reader.go | 29 ++++++++++++++--------------- 2 files changed, 22 insertions(+), 18 deletions(-) diff --git a/taskfile/override.go b/taskfile/override.go index b2393292f2..c35bfde9b4 100644 --- a/taskfile/override.go +++ b/taskfile/override.go @@ -18,6 +18,11 @@ var defaultOverrides = []string{ "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) @@ -31,11 +36,11 @@ func readAutomaticOverride(dir string, tf *ast.Taskfile) error { if err != nil { return fmt.Errorf("task: Failed to read Taskfile override: %w", err) } - override, err := decodeTaskfile(b, path, true) - if err != nil { + var override ast.Taskfile + if err := decodeTaskfile(b, path, &override, (*taskfileOverride)(&override)); err != nil { return err } - return tf.MergeOverride(override) + return tf.MergeOverride(&override) } return nil } diff --git a/taskfile/reader.go b/taskfile/reader.go index 5080c665ba..1aab3cb62a 100644 --- a/taskfile/reader.go +++ b/taskfile/reader.go @@ -160,7 +160,7 @@ 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 r.loadsOverride(node) { if err := readAutomaticOverride(filepath.Dir(node.Location()), vertex.Taskfile); err != nil { return err } @@ -265,18 +265,17 @@ 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 - var err error - if override { - err = yaml.Unmarshal(b, (*taskfileOverride)(&tf)) - } else { - err = yaml.Unmarshal(b, &tf) + if err := decodeTaskfile(b, node.Location(), &tf, &tf); err != nil { + return nil, err } - if err != nil { + 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, @@ -284,14 +283,14 @@ func decodeTaskfile(b []byte, location string, override bool) (*ast.Taskfile, er WithColumn(taskfileDecodeErr.Column), WithPadding(2), ) - return nil, taskfileDecodeErr.WithFileInfo(location, snippet.String()) + return taskfileDecodeErr.WithFileInfo(location, snippet.String()) } - return nil, &errors.TaskfileInvalidError{URI: filepathext.TryAbsToRel(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: location} + return &errors.TaskfileVersionCheckError{URI: location} } // Set the taskfile/task's locations @@ -307,7 +306,7 @@ func decodeTaskfile(b []byte, location string, override bool) (*ast.Taskfile, er } } - return &tf, nil + return nil } func (r *Reader) readNodeContent(ctx context.Context, node Node) ([]byte, error) { From e7f034f05f6cd4c20e0ad4d4da8eb24917eb9567 Mon Sep 17 00:00:00 2001 From: Valentin Maerten Date: Sun, 1 Nov 2026 14:19:41 +0100 Subject: [PATCH 10/19] feat(override): accept an override version older than the main Taskfile --- taskfile/ast/override.go | 6 ++++-- taskfile/override_test.go | 7 ++++--- website/src/next/docs/guide/running-tasks.md | 5 +++-- 3 files changed, 11 insertions(+), 7 deletions(-) diff --git a/taskfile/ast/override.go b/taskfile/ast/override.go index 4554114937..bc1eb90fa9 100644 --- a/taskfile/ast/override.go +++ b/taskfile/ast/override.go @@ -9,11 +9,13 @@ import ( // 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) { + // 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 match the main Taskfile (%s)", tf.Version), + Message: fmt.Sprintf("must be a v%d version no newer than the main Taskfile (%s)", tf.Version.Major(), tf.Version), } } if tf.Vars == nil { diff --git a/taskfile/override_test.go b/taskfile/override_test.go index 303daa1763..c504a57ec3 100644 --- a/taskfile/override_test.go +++ b/taskfile/override_test.go @@ -38,7 +38,7 @@ func readOverrideFixture(t *testing.T, dir string, opts ...ReaderOption) (*ast.T func TestAutomaticOverrideMerge(t *testing.T) { t.Parallel() dir := t.TempDir() - main := writeOverrideFixture(t, dir, "Taskfile.yml", `version: '3' + main := writeOverrideFixture(t, dir, "Taskfile.yml", `version: '3.1' vars: PORT: 3000 KEEP: original @@ -59,7 +59,7 @@ tasks: keep: echo keep empty: echo old `) - override := writeOverrideFixture(t, dir, "Taskfile.override.yml", `version: '3.0.0' + override := writeOverrideFixture(t, dir, "Taskfile.override.yml", `version: '3' vars: PORT: 4000 CONFIG: {map: {shared: local}} @@ -131,7 +131,8 @@ func TestAutomaticOverrideErrors(t *testing.T) { 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)"}, + {"newer version", "version: '3.1'", "(3.1.0) must be a v3 version no newer than the main Taskfile (3.0.0)"}, + {"older major version", "version: '2'", "(2.0.0) must be a v3 version"}, {"sequence", "- version: '3'", "taskfile override"}, } for _, key := range []string{"includes", "unknown", "<<"} { diff --git a/website/src/next/docs/guide/running-tasks.md b/website/src/next/docs/guide/running-tasks.md index b3316de79c..05d28dd498 100644 --- a/website/src/next/docs/guide/running-tasks.md +++ b/website/src/next/docs/guide/running-tasks.md @@ -182,8 +182,9 @@ vars: `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: +An override can set `vars`, `env` and `tasks`. Its `version` can't be newer +than the main Taskfile's, so `version: '3'` keeps working when the main Taskfile +bumps its version: - Variables and environment variables are merged: yours win, the others are kept. From fc605c42188f6f871cf287ad2a419fed7724de09 Mon Sep 17 00:00:00 2001 From: Valentin Maerten Date: Sun, 1 Nov 2026 14:21:28 +0100 Subject: [PATCH 11/19] docs(override): explain how to call Task again without losing the override --- website/src/next/docs/guide/running-tasks.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/website/src/next/docs/guide/running-tasks.md b/website/src/next/docs/guide/running-tasks.md index 05d28dd498..20dbf8c90c 100644 --- a/website/src/next/docs/guide/running-tasks.md +++ b/website/src/next/docs/guide/running-tasks.md @@ -213,3 +213,7 @@ 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. From e47f5c39f3691124e493e44109c855155be9338a Mon Sep 17 00:00:00 2001 From: Valentin Maerten Date: Sun, 1 Nov 2026 14:30:29 +0100 Subject: [PATCH 12/19] docs(override): escape template syntax in the Task call note --- website/src/next/docs/guide/running-tasks.md | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/website/src/next/docs/guide/running-tasks.md b/website/src/next/docs/guide/running-tasks.md index 20dbf8c90c..5986f0fa5f 100644 --- a/website/src/next/docs/guide/running-tasks.md +++ b/website/src/next/docs/guide/running-tasks.md @@ -214,6 +214,7 @@ To run without your override, name the Taskfile explicitly: 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. +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. From 337677d9ccffc1146ee21aaa0cc681312779122a Mon Sep 17 00:00:00 2001 From: Valentin Maerten Date: Sun, 1 Nov 2026 15:03:13 +0100 Subject: [PATCH 13/19] feat(override): allow dotenv files in a Taskfile override --- taskfile/ast/override.go | 6 ++++-- taskfile/override.go | 2 +- testdata/override/.env | 1 + testdata/override/.env.local | 1 + testdata/override/Taskfile.override.yml | 2 ++ testdata/override/Taskfile.yml | 4 +++- .../TestAutomaticOverride-subdirectory.golden | 2 +- .../testdata/TestAutomaticOverride-discovered.golden | 2 +- .../TestAutomaticOverride-explicit_directory.golden | 2 +- .../TestAutomaticOverride-explicit_file.golden | 2 +- website/src/next/docs/guide/running-tasks.md | 11 +++++++---- 11 files changed, 23 insertions(+), 12 deletions(-) create mode 100644 testdata/override/.env create mode 100644 testdata/override/.env.local diff --git a/taskfile/ast/override.go b/taskfile/ast/override.go index bc1eb90fa9..ba3618027d 100644 --- a/taskfile/ast/override.go +++ b/taskfile/ast/override.go @@ -2,12 +2,13 @@ package ast import ( "fmt" + "slices" "github.com/go-task/task/v3/errors" ) -// MergeOverride applies a local override: vars and env merge by key, tasks are -// replaced whole. +// MergeOverride applies a local override: vars and env merge by key, dotenv +// files take priority over the main ones, tasks are replaced whole. func (tf *Taskfile) MergeOverride(override *Taskfile) error { // An older version is fine, so bumping the shared Taskfile doesn't break // existing overrides. @@ -29,6 +30,7 @@ func (tf *Taskfile) MergeOverride(override *Taskfile) error { } 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) } diff --git a/taskfile/override.go b/taskfile/override.go index c35bfde9b4..e3fc7dfd35 100644 --- a/taskfile/override.go +++ b/taskfile/override.go @@ -56,7 +56,7 @@ func (tf *taskfileOverride) UnmarshalYAML(node *yaml.Node) error { for i := 0; i < len(node.Content); i += 2 { key := node.Content[i] switch key.Value { - case "version", "vars", "env", "tasks": + case "version", "vars", "env", "dotenv", "tasks": default: return errors.NewTaskfileDecodeError(nil, key). WithMessage("field %q is not allowed in a Taskfile override", key.Value) 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 index 8fa35bf804..8c62eb4bf6 100644 --- a/testdata/override/Taskfile.override.yml +++ b/testdata/override/Taskfile.override.yml @@ -6,6 +6,8 @@ vars: env: TASK_OVERRIDE_ENV: local +dotenv: [.env.local] + tasks: added: cmds: diff --git a/testdata/override/Taskfile.yml b/testdata/override/Taskfile.yml index 2296ba688a..28ff8952cb 100644 --- a/testdata/override/Taskfile.yml +++ b/testdata/override/Taskfile.yml @@ -6,7 +6,9 @@ vars: env: TASK_OVERRIDE_ENV: main +dotenv: [.env] + tasks: dev: cmds: - - echo {{.PORT}} $TASK_OVERRIDE_ENV + - echo {{.PORT}} $TASK_OVERRIDE_ENV $TASK_OVERRIDE_DOTENV diff --git a/testdata/override/child/testdata/TestAutomaticOverride-subdirectory.golden b/testdata/override/child/testdata/TestAutomaticOverride-subdirectory.golden index 195e1127c2..73deaf3603 100644 --- a/testdata/override/child/testdata/TestAutomaticOverride-subdirectory.golden +++ b/testdata/override/child/testdata/TestAutomaticOverride-subdirectory.golden @@ -1 +1 @@ -4000 local +4000 local local diff --git a/testdata/override/testdata/TestAutomaticOverride-discovered.golden b/testdata/override/testdata/TestAutomaticOverride-discovered.golden index 195e1127c2..73deaf3603 100644 --- a/testdata/override/testdata/TestAutomaticOverride-discovered.golden +++ b/testdata/override/testdata/TestAutomaticOverride-discovered.golden @@ -1 +1 @@ -4000 local +4000 local local diff --git a/testdata/override/testdata/TestAutomaticOverride-explicit_directory.golden b/testdata/override/testdata/TestAutomaticOverride-explicit_directory.golden index 195e1127c2..73deaf3603 100644 --- a/testdata/override/testdata/TestAutomaticOverride-explicit_directory.golden +++ b/testdata/override/testdata/TestAutomaticOverride-explicit_directory.golden @@ -1 +1 @@ -4000 local +4000 local local diff --git a/testdata/override/testdata/TestAutomaticOverride-explicit_file.golden b/testdata/override/testdata/TestAutomaticOverride-explicit_file.golden index 7bab9bc24c..f15a0af2e9 100644 --- a/testdata/override/testdata/TestAutomaticOverride-explicit_file.golden +++ b/testdata/override/testdata/TestAutomaticOverride-explicit_file.golden @@ -1 +1 @@ -3000 main +3000 main shared diff --git a/website/src/next/docs/guide/running-tasks.md b/website/src/next/docs/guide/running-tasks.md index 5986f0fa5f..3c556986fd 100644 --- a/website/src/next/docs/guide/running-tasks.md +++ b/website/src/next/docs/guide/running-tasks.md @@ -182,17 +182,20 @@ vars: `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`. Its `version` can't be newer -than the main Taskfile's, so `version: '3'` keeps working when the main Taskfile -bumps its version: +An override can set `vars`, `env`, `dotenv` and `tasks`. Its `version` can't be +newer than the main Taskfile's, so `version: '3'` keeps working when the main +Taskfile bumps its version: - 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. -Anything else at the root, such as `includes` or `dotenv`, is rejected. +Anything else at the root, such as `includes` or `output`, is rejected. ::: tip From fd07f9ddfebfa8aafc0bbb26a61efb5e171de0f4 Mon Sep 17 00:00:00 2001 From: Valentin Maerten Date: Sun, 1 Nov 2026 15:17:49 +0100 Subject: [PATCH 14/19] feat(override): allow adding includes in a Taskfile override --- taskfile/ast/override.go | 11 ++++- taskfile/override.go | 2 +- taskfile/override_test.go | 43 +++++++++++--------- website/src/next/docs/guide/running-tasks.md | 12 ++++-- 4 files changed, 42 insertions(+), 26 deletions(-) diff --git a/taskfile/ast/override.go b/taskfile/ast/override.go index ba3618027d..ba336e267e 100644 --- a/taskfile/ast/override.go +++ b/taskfile/ast/override.go @@ -7,8 +7,9 @@ import ( "github.com/go-task/task/v3/errors" ) -// MergeOverride applies a local override: vars and env merge by key, dotenv -// files take priority over the main ones, tasks are replaced whole. +// MergeOverride applies a local override: includes are added, vars and env +// merge by key, dotenv files take priority over the main ones, tasks are +// replaced whole. func (tf *Taskfile) MergeOverride(override *Taskfile) error { // An older version is fine, so bumping the shared Taskfile doesn't break // existing overrides. @@ -19,6 +20,12 @@ func (tf *Taskfile) MergeOverride(override *Taskfile) error { Message: fmt.Sprintf("must be a v%d version no newer than the main Taskfile (%s)", tf.Version.Major(), tf.Version), } } + 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", override.Location, namespace) + } + tf.Includes.Set(namespace, include) + } if tf.Vars == nil { tf.Vars = NewVars() } diff --git a/taskfile/override.go b/taskfile/override.go index e3fc7dfd35..a6978bd173 100644 --- a/taskfile/override.go +++ b/taskfile/override.go @@ -56,7 +56,7 @@ func (tf *taskfileOverride) UnmarshalYAML(node *yaml.Node) error { for i := 0; i < len(node.Content); i += 2 { key := node.Content[i] switch key.Value { - case "version", "vars", "env", "dotenv", "tasks": + case "version", "includes", "vars", "env", "dotenv", "tasks": default: return errors.NewTaskfileDecodeError(nil, key). WithMessage("field %q is not allowed in a Taskfile override", key.Value) diff --git a/taskfile/override_test.go b/taskfile/override_test.go index c504a57ec3..a1a19f0efa 100644 --- a/taskfile/override_test.go +++ b/taskfile/override_test.go @@ -135,7 +135,7 @@ func TestAutomaticOverrideErrors(t *testing.T) { {"older major version", "version: '2'", "(2.0.0) must be a v3 version"}, {"sequence", "- version: '3'", "taskfile override"}, } - for _, key := range []string{"includes", "unknown", "<<"} { + for _, key := range []string{"output", "unknown", "<<"} { tests = append(tests, struct{ name, content, message string }{key, "version: '3'\n" + key + ": null", "not allowed"}) } for _, tt := range tests { @@ -169,29 +169,34 @@ func TestAutomaticOverrideReadError(t *testing.T) { func TestAutomaticOverrideIncludes(t *testing.T) { t.Parallel() - for _, conflict := range []bool{false, true} { - t.Run(fmt.Sprint(conflict), func(t *testing.T) { + const main = "version: '3'\nvars: {INCLUDE: missing}\nincludes:\n docs: '{{.INCLUDE}}/Taskfile.yml'\n" + tests := []struct { + name, main, override, task, taskfile, err string + }{ + {name: "var picks include", main: main, override: "vars: {INCLUDE: docs}\n", task: "docs:build", taskfile: "docs/Taskfile.yml"}, + {name: "task conflicts with include", main: main, override: "vars: {INCLUDE: docs}\ntasks:\n docs:build: echo local\n", err: "docs:build"}, + {name: "added include", main: "version: '3'\n", override: "includes:\n extra: extra/Taskfile.yml\n", task: "extra:build", taskfile: "extra/Taskfile.yml"}, + {name: "redefined include", main: main, override: "includes:\n docs: extra/Taskfile.yml\n", err: `can't redefine include "docs"`}, + } + for _, tt := range tests { + t.Run(tt.name, 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.yml", tt.main) + writeOverrideFixture(t, dir, "Taskfile.override.yml", "version: '3'\n"+tt.override) + for _, sub := range []string{"docs", "extra"} { + writeOverrideFixture(t, dir, sub+"/Taskfile.yml", "version: '3'\ntasks:\n build: echo "+sub+"\n") + writeOverrideFixture(t, dir, sub+"/Taskfile.override.yml", "invalid YAML: [") } - 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) + if tt.err != "" { + require.ErrorContains(t, err, tt.err) + return } + require.NoError(t, err) + task, ok := tf.Tasks.Get(tt.task) + require.True(t, ok) + assert.Equal(t, filepath.Join(dir, tt.taskfile), task.Location.Taskfile) }) } } diff --git a/website/src/next/docs/guide/running-tasks.md b/website/src/next/docs/guide/running-tasks.md index 3c556986fd..3981606146 100644 --- a/website/src/next/docs/guide/running-tasks.md +++ b/website/src/next/docs/guide/running-tasks.md @@ -182,9 +182,13 @@ vars: `task dev` now prints `PORT=4000`. Add `Taskfile.override.yml` to `.gitignore` so it stays on your machine. -An override can set `vars`, `env`, `dotenv` and `tasks`. Its `version` can't be -newer than the main Taskfile's, so `version: '3'` keeps working when the main -Taskfile bumps its version: +An override can set `includes`, `vars`, `env`, `dotenv` and `tasks`. Its +`version` can't be newer than the main Taskfile's, so `version: '3'` keeps +working when the main Taskfile bumps its version: + +- 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. @@ -195,7 +199,7 @@ Taskfile bumps its version: 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 `output`, is rejected. +Anything else at the root, such as `output` or `silent`, is rejected. ::: tip From 54e8233ad80c5f609e47d33dfe79684aa25f7b81 Mon Sep 17 00:00:00 2001 From: Valentin Maerten Date: Sun, 1 Nov 2026 15:28:08 +0100 Subject: [PATCH 15/19] feat(override): allow setting the output style in a Taskfile override --- taskfile/ast/override.go | 9 ++++++--- taskfile/override.go | 2 +- taskfile/override_test.go | 2 +- testdata/override/Taskfile.override.yml | 2 ++ .../testdata/TestAutomaticOverride-subdirectory.golden | 2 +- .../TestAutomaticOverride-added_task_location.golden | 2 +- .../testdata/TestAutomaticOverride-discovered.golden | 2 +- .../TestAutomaticOverride-explicit_directory.golden | 2 +- website/src/next/docs/guide/running-tasks.md | 9 ++++++--- 9 files changed, 20 insertions(+), 12 deletions(-) diff --git a/taskfile/ast/override.go b/taskfile/ast/override.go index ba336e267e..2386498428 100644 --- a/taskfile/ast/override.go +++ b/taskfile/ast/override.go @@ -7,9 +7,9 @@ import ( "github.com/go-task/task/v3/errors" ) -// MergeOverride applies a local override: includes are added, vars and env -// merge by key, dotenv files take priority over the main ones, tasks are -// replaced whole. +// MergeOverride applies a local override: output is replaced, includes are +// added, vars and env merge by key, dotenv files take priority over the main +// ones, tasks are replaced whole. func (tf *Taskfile) MergeOverride(override *Taskfile) error { // An older version is fine, so bumping the shared Taskfile doesn't break // existing overrides. @@ -20,6 +20,9 @@ func (tf *Taskfile) MergeOverride(override *Taskfile) error { 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 + } 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", override.Location, namespace) diff --git a/taskfile/override.go b/taskfile/override.go index a6978bd173..ca93435aef 100644 --- a/taskfile/override.go +++ b/taskfile/override.go @@ -56,7 +56,7 @@ func (tf *taskfileOverride) UnmarshalYAML(node *yaml.Node) error { for i := 0; i < len(node.Content); i += 2 { key := node.Content[i] switch key.Value { - case "version", "includes", "vars", "env", "dotenv", "tasks": + case "version", "output", "includes", "vars", "env", "dotenv", "tasks": default: return errors.NewTaskfileDecodeError(nil, key). WithMessage("field %q is not allowed in a Taskfile override", key.Value) diff --git a/taskfile/override_test.go b/taskfile/override_test.go index a1a19f0efa..b941d016e4 100644 --- a/taskfile/override_test.go +++ b/taskfile/override_test.go @@ -135,7 +135,7 @@ func TestAutomaticOverrideErrors(t *testing.T) { {"older major version", "version: '2'", "(2.0.0) must be a v3 version"}, {"sequence", "- version: '3'", "taskfile override"}, } - for _, key := range []string{"output", "unknown", "<<"} { + for _, key := range []string{"silent", "unknown", "<<"} { tests = append(tests, struct{ name, content, message string }{key, "version: '3'\n" + key + ": null", "not allowed"}) } for _, tt := range tests { diff --git a/testdata/override/Taskfile.override.yml b/testdata/override/Taskfile.override.yml index 8c62eb4bf6..3d61122a5a 100644 --- a/testdata/override/Taskfile.override.yml +++ b/testdata/override/Taskfile.override.yml @@ -1,5 +1,7 @@ version: '3' +output: prefixed + vars: PORT: 4000 diff --git a/testdata/override/child/testdata/TestAutomaticOverride-subdirectory.golden b/testdata/override/child/testdata/TestAutomaticOverride-subdirectory.golden index 73deaf3603..75840c7e30 100644 --- a/testdata/override/child/testdata/TestAutomaticOverride-subdirectory.golden +++ b/testdata/override/child/testdata/TestAutomaticOverride-subdirectory.golden @@ -1 +1 @@ -4000 local local +[dev] 4000 local local diff --git a/testdata/override/testdata/TestAutomaticOverride-added_task_location.golden b/testdata/override/testdata/TestAutomaticOverride-added_task_location.golden index 37c18d8907..ac40c9185c 100644 --- a/testdata/override/testdata/TestAutomaticOverride-added_task_location.golden +++ b/testdata/override/testdata/TestAutomaticOverride-added_task_location.golden @@ -1 +1 @@ -{{.TEST_DIR}}/testdata/override/Taskfile.override.yml {{.TEST_DIR}}/testdata/override/Taskfile.yml +[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 index 73deaf3603..75840c7e30 100644 --- a/testdata/override/testdata/TestAutomaticOverride-discovered.golden +++ b/testdata/override/testdata/TestAutomaticOverride-discovered.golden @@ -1 +1 @@ -4000 local local +[dev] 4000 local local diff --git a/testdata/override/testdata/TestAutomaticOverride-explicit_directory.golden b/testdata/override/testdata/TestAutomaticOverride-explicit_directory.golden index 73deaf3603..75840c7e30 100644 --- a/testdata/override/testdata/TestAutomaticOverride-explicit_directory.golden +++ b/testdata/override/testdata/TestAutomaticOverride-explicit_directory.golden @@ -1 +1 @@ -4000 local local +[dev] 4000 local local diff --git a/website/src/next/docs/guide/running-tasks.md b/website/src/next/docs/guide/running-tasks.md index 3981606146..3b6247feb7 100644 --- a/website/src/next/docs/guide/running-tasks.md +++ b/website/src/next/docs/guide/running-tasks.md @@ -182,10 +182,13 @@ vars: `task dev` now prints `PORT=4000`. Add `Taskfile.override.yml` to `.gitignore` so it stays on your machine. -An override can set `includes`, `vars`, `env`, `dotenv` and `tasks`. Its -`version` can't be newer than the main Taskfile's, so `version: '3'` keeps +An override can set `output`, `includes`, `vars`, `env`, `dotenv` and `tasks`. +Its `version` can't be newer than the main Taskfile's, so `version: '3'` keeps working when the main Taskfile bumps its version: +- `output` replaces the main Taskfile's [output style](./output.md); the + `--output` flag still takes 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. @@ -199,7 +202,7 @@ working when the main Taskfile bumps its version: 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 `output` or `silent`, is rejected. +Anything else at the root, such as `silent` or `run`, is rejected. ::: tip From 0047acf23edd7465baf7aad25fa68abc6baca5d9 Mon Sep 17 00:00:00 2001 From: Valentin Maerten Date: Sun, 1 Nov 2026 15:31:47 +0100 Subject: [PATCH 16/19] feat(override): allow method, run, interval, set, shopt and use_gitignore in a Taskfile override --- taskfile/ast/override.go | 25 +++++++++++++++++--- taskfile/override.go | 2 +- taskfile/override_test.go | 18 ++++++++++++++ website/src/next/docs/guide/running-tasks.md | 19 +++++++-------- 4 files changed, 50 insertions(+), 14 deletions(-) diff --git a/taskfile/ast/override.go b/taskfile/ast/override.go index 2386498428..5faebd1a3f 100644 --- a/taskfile/ast/override.go +++ b/taskfile/ast/override.go @@ -7,9 +7,9 @@ import ( "github.com/go-task/task/v3/errors" ) -// MergeOverride applies a local override: output is replaced, includes are -// added, vars and env merge by key, dotenv files take priority over the main -// ones, tasks are replaced whole. +// MergeOverride applies a local override: settings it declares replace the +// main ones, includes are added, vars and env merge by key, dotenv files take +// priority over the main ones, tasks are replaced whole. func (tf *Taskfile) MergeOverride(override *Taskfile) error { // An older version is fine, so bumping the shared Taskfile doesn't break // existing overrides. @@ -23,6 +23,25 @@ func (tf *Taskfile) MergeOverride(override *Taskfile) error { 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", override.Location, namespace) diff --git a/taskfile/override.go b/taskfile/override.go index ca93435aef..cc25f5a367 100644 --- a/taskfile/override.go +++ b/taskfile/override.go @@ -56,7 +56,7 @@ func (tf *taskfileOverride) UnmarshalYAML(node *yaml.Node) error { for i := 0; i < len(node.Content); i += 2 { key := node.Content[i] switch key.Value { - case "version", "output", "includes", "vars", "env", "dotenv", "tasks": + 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) diff --git a/taskfile/override_test.go b/taskfile/override_test.go index b941d016e4..9314fd109c 100644 --- a/taskfile/override_test.go +++ b/taskfile/override_test.go @@ -8,6 +8,7 @@ import ( "path/filepath" "slices" "testing" + "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -58,6 +59,12 @@ tasks: silent: true keep: echo keep empty: echo old +method: timestamp +run: once +interval: 1s +set: [pipefail] +shopt: [globstar] +use_gitignore: true `) override := writeOverrideFixture(t, dir, "Taskfile.override.yml", `version: '3' vars: @@ -73,6 +80,11 @@ tasks: cmds: ['echo {{.PORT}}'] added: echo added empty: null +method: none +run: when_changed +interval: 2s +set: [] +use_gitignore: false `) tf, err := readOverrideFixture(t, dir, WithAutomaticOverride(true)) require.NoError(t, err) @@ -104,6 +116,12 @@ tasks: require.True(t, ok) assert.Empty(t, empty.Cmds) assert.Equal(t, override, empty.Location.Taskfile) + 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 TestAutomaticOverrideNames(t *testing.T) { diff --git a/website/src/next/docs/guide/running-tasks.md b/website/src/next/docs/guide/running-tasks.md index 3b6247feb7..aaccd66516 100644 --- a/website/src/next/docs/guide/running-tasks.md +++ b/website/src/next/docs/guide/running-tasks.md @@ -182,17 +182,18 @@ vars: `task dev` now prints `PORT=4000`. Add `Taskfile.override.yml` to `.gitignore` so it stays on your machine. -An override can set `output`, `includes`, `vars`, `env`, `dotenv` and `tasks`. -Its `version` can't be newer than the main Taskfile's, so `version: '3'` keeps -working when the main Taskfile bumps its version: - -- `output` replaces the main Taskfile's [output style](./output.md); the - `--output` flag still takes priority. - +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. @@ -202,8 +203,6 @@ working when the main Taskfile bumps its version: 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 `silent` or `run`, is rejected. - ::: tip An overridden variable keeps its place in the main Taskfile, so its value can From c7746a0019dc2320be80aa02f4c565253bb852fd Mon Sep 17 00:00:00 2001 From: Valentin Maerten Date: Sun, 1 Nov 2026 15:46:25 +0100 Subject: [PATCH 17/19] chore(override): trim comments --- executor_test.go | 3 +-- taskfile/ast/override.go | 4 +--- 2 files changed, 2 insertions(+), 5 deletions(-) diff --git a/executor_test.go b/executor_test.go index fc87213a10..4103aae797 100644 --- a/executor_test.go +++ b/executor_test.go @@ -1340,8 +1340,7 @@ func TestAutomaticOverride(t *testing.T) { WithTask("added"), WithFixtureTemplating(), ) - // Only the discovered root's directory is searched, so the invalid - // override in child/ is ignored. + // The invalid override in child/ must be ignored. NewExecutorTest(t, WithName("subdirectory"), WithExecutorOptions(task.WithDir(dir+"/child"), task.WithSilent(true)), diff --git a/taskfile/ast/override.go b/taskfile/ast/override.go index 5faebd1a3f..fc55efd939 100644 --- a/taskfile/ast/override.go +++ b/taskfile/ast/override.go @@ -7,9 +7,7 @@ import ( "github.com/go-task/task/v3/errors" ) -// MergeOverride applies a local override: settings it declares replace the -// main ones, includes are added, vars and env merge by key, dotenv files take -// priority over the main ones, tasks are replaced whole. +// 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. From 14856e05dd17a6e469223a9e2304d8f07b3a1b9a Mon Sep 17 00:00:00 2001 From: Valentin Maerten Date: Sun, 1 Nov 2026 15:49:18 +0100 Subject: [PATCH 18/19] test(override): move behaviour tests to golden fixtures --- executor_test.go | 28 +++ taskfile/override_test.go | 191 ++---------------- .../forbidden_key/Taskfile.override.yml | 2 + .../errors/forbidden_key/Taskfile.yml | 4 + ...rride-error_forbidden_key-err-setup.golden | 5 + ...tomaticOverride-error_forbidden_key.golden | 0 .../errors/invalid_yaml/Taskfile.override.yml | 1 + .../override/errors/invalid_yaml/Taskfile.yml | 4 + ...erride-error_invalid_yaml-err-setup.golden | 2 + ...utomaticOverride-error_invalid_yaml.golden | 0 .../missing_version/Taskfile.override.yml | 2 + .../errors/missing_version/Taskfile.yml | 4 + ...ide-error_missing_version-err-setup.golden | 1 + ...maticOverride-error_missing_version.golden | 0 .../newer_version/Taskfile.override.yml | 1 + .../errors/newer_version/Taskfile.yml | 4 + ...rride-error_newer_version-err-setup.golden | 2 + ...tomaticOverride-error_newer_version.golden | 0 .../redefined_include/Taskfile.override.yml | 4 + .../errors/redefined_include/Taskfile.yml | 4 + ...e-error_redefined_include-err-setup.golden | 1 + ...ticOverride-error_redefined_include.golden | 0 .../override/includes/Taskfile.override.yml | 7 + testdata/override/includes/Taskfile.yml | 7 + .../includes/docs/Taskfile.override.yml | 1 + testdata/override/includes/docs/Taskfile.yml | 4 + testdata/override/includes/extra/Taskfile.yml | 4 + .../TestAutomaticOverride-include_docs.golden | 1 + ...TestAutomaticOverride-include_extra.golden | 1 + testdata/override/merge/Taskfile.override.yml | 15 ++ testdata/override/merge/Taskfile.yml | 19 ++ .../TestAutomaticOverride-merge.golden | 1 + 32 files changed, 141 insertions(+), 179 deletions(-) create mode 100644 testdata/override/errors/forbidden_key/Taskfile.override.yml create mode 100644 testdata/override/errors/forbidden_key/Taskfile.yml create mode 100644 testdata/override/errors/forbidden_key/testdata/TestAutomaticOverride-error_forbidden_key-err-setup.golden create mode 100644 testdata/override/errors/forbidden_key/testdata/TestAutomaticOverride-error_forbidden_key.golden create mode 100644 testdata/override/errors/invalid_yaml/Taskfile.override.yml create mode 100644 testdata/override/errors/invalid_yaml/Taskfile.yml create mode 100644 testdata/override/errors/invalid_yaml/testdata/TestAutomaticOverride-error_invalid_yaml-err-setup.golden create mode 100644 testdata/override/errors/invalid_yaml/testdata/TestAutomaticOverride-error_invalid_yaml.golden create mode 100644 testdata/override/errors/missing_version/Taskfile.override.yml create mode 100644 testdata/override/errors/missing_version/Taskfile.yml create mode 100644 testdata/override/errors/missing_version/testdata/TestAutomaticOverride-error_missing_version-err-setup.golden create mode 100644 testdata/override/errors/missing_version/testdata/TestAutomaticOverride-error_missing_version.golden create mode 100644 testdata/override/errors/newer_version/Taskfile.override.yml create mode 100644 testdata/override/errors/newer_version/Taskfile.yml create mode 100644 testdata/override/errors/newer_version/testdata/TestAutomaticOverride-error_newer_version-err-setup.golden create mode 100644 testdata/override/errors/newer_version/testdata/TestAutomaticOverride-error_newer_version.golden create mode 100644 testdata/override/errors/redefined_include/Taskfile.override.yml create mode 100644 testdata/override/errors/redefined_include/Taskfile.yml create mode 100644 testdata/override/errors/redefined_include/testdata/TestAutomaticOverride-error_redefined_include-err-setup.golden create mode 100644 testdata/override/errors/redefined_include/testdata/TestAutomaticOverride-error_redefined_include.golden create mode 100644 testdata/override/includes/Taskfile.override.yml create mode 100644 testdata/override/includes/Taskfile.yml create mode 100644 testdata/override/includes/docs/Taskfile.override.yml create mode 100644 testdata/override/includes/docs/Taskfile.yml create mode 100644 testdata/override/includes/extra/Taskfile.yml create mode 100644 testdata/override/includes/testdata/TestAutomaticOverride-include_docs.golden create mode 100644 testdata/override/includes/testdata/TestAutomaticOverride-include_extra.golden create mode 100644 testdata/override/merge/Taskfile.override.yml create mode 100644 testdata/override/merge/Taskfile.yml create mode 100644 testdata/override/merge/testdata/TestAutomaticOverride-merge.golden diff --git a/executor_test.go b/executor_test.go index 4103aae797..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" @@ -1326,6 +1327,8 @@ func TestIf(t *testing.T) { } } +var ansiEscape = regexp.MustCompile(`\x1b\[[0-9;]*m`) + func TestAutomaticOverride(t *testing.T) { t.Parallel() const dir = "testdata/override" @@ -1346,6 +1349,31 @@ func TestAutomaticOverride(t *testing.T) { 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)), diff --git a/taskfile/override_test.go b/taskfile/override_test.go index 9314fd109c..870f652b63 100644 --- a/taskfile/override_test.go +++ b/taskfile/override_test.go @@ -6,59 +6,24 @@ import ( "net/http/httptest" "os" "path/filepath" - "slices" "testing" "time" "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) { +func writeOverrideFixture(t *testing.T, dir, name, content string) { 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() + require.NoError(t, os.WriteFile(filepath.Join(dir, name), []byte(content), 0o600)) } -func TestAutomaticOverrideMerge(t *testing.T) { +// 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() - main := writeOverrideFixture(t, dir, "Taskfile.yml", `version: '3.1' -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 + writeOverrideFixture(t, dir, "Taskfile.yml", `version: '3' method: timestamp run: once interval: 1s @@ -66,56 +31,19 @@ set: [pipefail] shopt: [globstar] use_gitignore: true `) - override := writeOverrideFixture(t, dir, "Taskfile.override.yml", `version: '3' -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 + writeOverrideFixture(t, dir, "Taskfile.override.yml", `version: '3' method: none run: when_changed interval: 2s set: [] use_gitignore: false `) - tf, err := readOverrideFixture(t, dir, WithAutomaticOverride(true)) + 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, 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) assert.Equal(t, "none", tf.Method) assert.Equal(t, "when_changed", tf.Run) assert.Equal(t, 2*time.Second, tf.Interval) @@ -124,101 +52,6 @@ use_gitignore: false assert.Equal(t, new(false), tf.UseGitignore) } -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"}, - {"newer version", "version: '3.1'", "(3.1.0) must be a v3 version no newer than the main Taskfile (3.0.0)"}, - {"older major version", "version: '2'", "(2.0.0) must be a v3 version"}, - {"sequence", "- version: '3'", "taskfile override"}, - } - for _, key := range []string{"silent", "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() - const main = "version: '3'\nvars: {INCLUDE: missing}\nincludes:\n docs: '{{.INCLUDE}}/Taskfile.yml'\n" - tests := []struct { - name, main, override, task, taskfile, err string - }{ - {name: "var picks include", main: main, override: "vars: {INCLUDE: docs}\n", task: "docs:build", taskfile: "docs/Taskfile.yml"}, - {name: "task conflicts with include", main: main, override: "vars: {INCLUDE: docs}\ntasks:\n docs:build: echo local\n", err: "docs:build"}, - {name: "added include", main: "version: '3'\n", override: "includes:\n extra: extra/Taskfile.yml\n", task: "extra:build", taskfile: "extra/Taskfile.yml"}, - {name: "redefined include", main: main, override: "includes:\n docs: extra/Taskfile.yml\n", err: `can't redefine include "docs"`}, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - t.Parallel() - dir := t.TempDir() - writeOverrideFixture(t, dir, "Taskfile.yml", tt.main) - writeOverrideFixture(t, dir, "Taskfile.override.yml", "version: '3'\n"+tt.override) - for _, sub := range []string{"docs", "extra"} { - writeOverrideFixture(t, dir, sub+"/Taskfile.yml", "version: '3'\ntasks:\n build: echo "+sub+"\n") - writeOverrideFixture(t, dir, sub+"/Taskfile.override.yml", "invalid YAML: [") - } - tf, err := readOverrideFixture(t, dir, WithAutomaticOverride(true)) - if tt.err != "" { - require.ErrorContains(t, err, tt.err) - return - } - require.NoError(t, err) - task, ok := tf.Tasks.Get(tt.task) - require.True(t, ok) - assert.Equal(t, filepath.Join(dir, tt.taskfile), task.Location.Taskfile) - }) - } -} - func TestAutomaticOverrideRemote(t *testing.T) { t.Parallel() dir := t.TempDir() 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 From ca8672c1a418b16da95cb7aeccbf16d7bde3319f Mon Sep 17 00:00:00 2001 From: Valentin Maerten Date: Sun, 1 Nov 2026 16:01:30 +0100 Subject: [PATCH 19/19] fix(override): use forward slashes in the redefined include error --- taskfile/ast/override.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/taskfile/ast/override.go b/taskfile/ast/override.go index fc55efd939..ed9afd8517 100644 --- a/taskfile/ast/override.go +++ b/taskfile/ast/override.go @@ -2,6 +2,7 @@ package ast import ( "fmt" + "path/filepath" "slices" "github.com/go-task/task/v3/errors" @@ -42,7 +43,7 @@ func (tf *Taskfile) MergeOverride(override *Taskfile) error { } 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", override.Location, namespace) + return fmt.Errorf("task: Taskfile override %q can't redefine include %q", filepath.ToSlash(override.Location), namespace) } tf.Includes.Set(namespace, include) }