From 16efc84d99c963afc9f1aa3314ad9b649c312d53 Mon Sep 17 00:00:00 2001 From: Valentin Maerten Date: Sun, 27 Sep 2026 10:38:57 +0200 Subject: [PATCH 1/7] 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 e7a3de4912..b81ba18593 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 @@ -156,6 +157,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 @@ -256,8 +262,18 @@ func (r *Reader) readNode(ctx context.Context, node Node) (*ast.Taskfile, error) return nil, err } + return decodeTaskfile(b, node.Location(), false) +} + +func decodeTaskfile(b []byte, location string, override bool) (*ast.Taskfile, error) { var tf ast.Taskfile - if err := yaml.Unmarshal(b, &tf); err != nil { + var err error + if override { + err = yaml.Unmarshal(b, (*taskfileOverride)(&tf)) + } else { + err = yaml.Unmarshal(b, &tf) + } + if err != nil { // Decode the taskfile and add the file info the any errors if taskfileDecodeErr, ok := errors.AsType[*errors.TaskfileDecodeError](err); ok { snippet := NewSnippet(b, @@ -265,18 +281,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 c1e08cc4ded3c736713011323ef0545a5a2fd86d Mon Sep 17 00:00:00 2001 From: Valentin Maerten Date: Sat, 24 Oct 2026 15:58:51 +0200 Subject: [PATCH 2/7] 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 b81ba18593..0b39ac6e56 100644 --- a/taskfile/reader.go +++ b/taskfile/reader.go @@ -5,6 +5,7 @@ import ( "fmt" "net/url" "os" + "path/filepath" "slices" "sync" "time" @@ -157,8 +158,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 8483baa20e7390c55acf2bde7fdc56e0aab5052a Mon Sep 17 00:00:00 2001 From: Valentin Maerten Date: Sat, 24 Oct 2026 16:00:32 +0200 Subject: [PATCH 3/7] 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 e0ce4e2786..184e91b163 100644 --- a/executor_test.go +++ b/executor_test.go @@ -1289,3 +1289,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 3ead56af426c9b213a947ae02351bb31d86e98ff Mon Sep 17 00:00:00 2001 From: Valentin Maerten Date: Sat, 24 Oct 2026 16:00:47 +0200 Subject: [PATCH 4/7] 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 184e91b163..c2b09316d1 100644 --- a/executor_test.go +++ b/executor_test.go @@ -1316,4 +1316,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 588bd3e2f5e0345f22b9cb38e76cd6acb6103c8e Mon Sep 17 00:00:00 2001 From: Valentin Maerten Date: Sat, 24 Oct 2026 16:01:29 +0200 Subject: [PATCH 5/7] 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 e24fe6eeb7d18812124d4e9c67a57b669d7de4a7 Mon Sep 17 00:00:00 2001 From: Valentin Maerten Date: Sat, 24 Oct 2026 16:01:55 +0200 Subject: [PATCH 6/7] 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 08e928126173df8fca22b0480b040b495bd4c2e7 Mon Sep 17 00:00:00 2001 From: Valentin Maerten Date: Sat, 24 Oct 2026 16:08:03 +0200 Subject: [PATCH 7/7] 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