Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
8526338
feat: load local Taskfile overrides automatically
vmaerten Sep 27, 2026
2204c34
refactor(override): pass the automatic flag through setup instead of …
vmaerten Oct 24, 2026
1c2bc7a
test(override): move integration tests to golden fixtures and drop re…
vmaerten Oct 24, 2026
ee22068
fix(override): load the override when the entrypoint is a directory
vmaerten Oct 24, 2026
848f37e
fix(override): report override read and version errors with context
vmaerten Oct 24, 2026
08cef4f
docs(override): shorten the local override section and document its l…
vmaerten Oct 24, 2026
d433cac
docs(override): rephrase the local override section
vmaerten Oct 24, 2026
8935e77
test(override): drop the --global test, covered by the discovered case
vmaerten Nov 1, 2026
6910669
refactor(override): pass the decode target instead of a flag and extr…
vmaerten Nov 1, 2026
e7f034f
feat(override): accept an override version older than the main Taskfile
vmaerten Nov 1, 2026
fc605c4
docs(override): explain how to call Task again without losing the ove…
vmaerten Nov 1, 2026
e47f5c3
docs(override): escape template syntax in the Task call note
vmaerten Nov 1, 2026
337677d
feat(override): allow dotenv files in a Taskfile override
vmaerten Nov 1, 2026
fd07f9d
feat(override): allow adding includes in a Taskfile override
vmaerten Nov 1, 2026
54e8233
feat(override): allow setting the output style in a Taskfile override
vmaerten Nov 1, 2026
0047acf
feat(override): allow method, run, interval, set, shopt and use_gitig…
vmaerten Nov 1, 2026
c7746a0
chore(override): trim comments
vmaerten Nov 1, 2026
14856e0
test(override): move behaviour tests to golden fixtures
vmaerten Nov 1, 2026
ca8672c
fix(override): use forward slashes in the redefined include error
vmaerten Nov 1, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 60 additions & 0 deletions executor_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"fmt"
"os"
"path/filepath"
"regexp"
"testing"

"github.com/sebdah/goldie/v2"
Expand Down Expand Up @@ -1325,3 +1326,62 @@ func TestIf(t *testing.T) {
NewExecutorTest(t, opts...)
}
}

var ansiEscape = regexp.MustCompile(`\x1b\[[0-9;]*m`)

func TestAutomaticOverride(t *testing.T) {
t.Parallel()
const dir = "testdata/override"
NewExecutorTest(t,
WithName("discovered"),
WithExecutorOptions(task.WithDir(dir), task.WithSilent(true)),
WithTask("dev"),
)
NewExecutorTest(t,
WithName("added task location"),
WithExecutorOptions(task.WithDir(dir), task.WithSilent(true)),
WithTask("added"),
WithFixtureTemplating(),
)
// The invalid override in child/ must be ignored.
NewExecutorTest(t,
WithName("subdirectory"),
WithExecutorOptions(task.WithDir(dir+"/child"), task.WithSilent(true)),
WithTask("dev"),
)
NewExecutorTest(t,
WithName("merge"),
WithExecutorOptions(task.WithDir(dir+"/merge"), task.WithSilent(true)),
WithTask("dev"),
)
for _, include := range []string{"docs", "extra"} {
NewExecutorTest(t,
WithName("include "+include),
WithExecutorOptions(task.WithDir(dir+"/includes"), task.WithSilent(true)),
WithTask(include+":build"),
)
}
for _, name := range []string{"invalid_yaml", "missing_version", "newer_version", "forbidden_key", "redefined_include"} {
NewExecutorTest(t,
WithName("error "+name),
WithExecutorOptions(task.WithDir(dir+"/errors/"+name)),
WithSetupError(),
WithFixtureTemplating(),
// Decode errors print the raw path and a colored snippet.
WithPostProcessFn(func(_ *testing.T, b []byte) []byte {
b = ansiEscape.ReplaceAll(b, nil)
return bytes.ReplaceAll(b, []byte(`\`), []byte("/"))
}),
)
}
NewExecutorTest(t,
WithName("explicit file"),
WithExecutorOptions(task.WithDir(dir), task.WithEntrypoint(dir+"/Taskfile.yml"), task.WithSilent(true)),
WithTask("dev"),
)
NewExecutorTest(t,
WithName("explicit directory"),
WithExecutorOptions(task.WithDir(dir), task.WithEntrypoint(dir), task.WithSilent(true)),
WithTask("dev"),
)
}
16 changes: 14 additions & 2 deletions setup.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,14 +25,15 @@ import (

func (e *Executor) Setup() error {
e.setupLogger()
automaticOverride := discoversTaskfile(e.Entrypoint)
node, err := e.getRootNode()
if err != nil {
return err
}
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()
Expand All @@ -53,6 +54,16 @@ func (e *Executor) Setup() error {
return nil
}

// discoversTaskfile reports whether Task picks the Taskfile name itself, as
// opposed to the user naming the file.
func discoversTaskfile(entrypoint string) bool {
if entrypoint == "" {
return true
}
info, err := os.Stat(entrypoint)
return err == nil && info.IsDir()
}

func (e *Executor) getRootNode() (taskfile.Node, error) {
node, err := taskfile.NewRootNode(e.Entrypoint, e.Dir, e.Insecure, e.Timeout,
taskfile.WithCACert(e.CACert),
Expand All @@ -72,7 +83,7 @@ func (e *Executor) getRootNode() (taskfile.Node, error) {
return node, err
}

func (e *Executor) readTaskfile(node taskfile.Node) error {
func (e *Executor) readTaskfile(node taskfile.Node, automaticOverride bool) error {
ctx, cf := context.WithTimeout(context.Background(), e.Timeout)
defer cf()
debugFunc := func(s string) {
Expand All @@ -82,6 +93,7 @@ func (e *Executor) readTaskfile(node taskfile.Node) error {
return e.Logger.Prompt(logger.Yellow, s, "n", "y", "yes")
}
reader := taskfile.NewReader(
taskfile.WithAutomaticOverride(automaticOverride),
taskfile.WithInsecure(e.Insecure),
taskfile.WithDownload(e.Download),
taskfile.WithOffline(e.Offline),
Expand Down
66 changes: 66 additions & 0 deletions taskfile/ast/override.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
package ast

import (
"fmt"
"path/filepath"
"slices"

"github.com/go-task/task/v3/errors"
)

// MergeOverride applies a local Taskfile override to tf.
func (tf *Taskfile) MergeOverride(override *Taskfile) error {
// An older version is fine, so bumping the shared Taskfile doesn't break
// existing overrides.
if override.Version.Major() != tf.Version.Major() || override.Version.GreaterThan(tf.Version) {
return &errors.TaskfileVersionCheckError{
URI: override.Location,
SchemaVersion: override.Version,
Message: fmt.Sprintf("must be a v%d version no newer than the main Taskfile (%s)", tf.Version.Major(), tf.Version),
}
}
if override.Output.IsSet() {
tf.Output = override.Output
}
if override.Method != "" {
tf.Method = override.Method
}
if override.Run != "" {
tf.Run = override.Run
}
if override.Interval != 0 {
tf.Interval = override.Interval
}
if override.UseGitignore != nil {
tf.UseGitignore = override.UseGitignore
}
// A present but empty list clears the main one.
if override.Set != nil {
tf.Set = override.Set
}
if override.Shopt != nil {
tf.Shopt = override.Shopt
}
for namespace, include := range override.Includes.All() {
if _, ok := tf.Includes.Get(namespace); ok {
return fmt.Errorf("task: Taskfile override %q can't redefine include %q", filepath.ToSlash(override.Location), namespace)
}
tf.Includes.Set(namespace, include)
}
if tf.Vars == nil {
tf.Vars = NewVars()
}
if tf.Env == nil {
tf.Env = NewVars()
}
if tf.Tasks == nil {
tf.Tasks = NewTasks()
}
tf.Vars.Merge(override.Vars, nil)
tf.Env.Merge(override.Env, nil)
tf.Dotenv = append(slices.Clone(override.Dotenv), tf.Dotenv...)
for name, task := range override.Tasks.All(nil) {
tf.Tasks.Set(name, task)
}
return nil
}
14 changes: 14 additions & 0 deletions taskfile/options.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,20 @@ package taskfile

import "time"

// WithAutomaticOverride loads a Taskfile override beside a local root Taskfile.
// Remote, stdin and included Taskfiles never load one.
func WithAutomaticOverride(enabled bool) *automaticOverrideOption {
return &automaticOverrideOption{enabled: enabled}
}

type automaticOverrideOption struct {
enabled bool
}

func (o *automaticOverrideOption) ApplyToReader(r *Reader) {
r.automaticOverride = o.enabled
}

// WithInsecure allows the [Reader] to make insecure connections when reading
// remote taskfiles. By default, insecure connections are rejected.
func WithInsecure(insecure bool) *insecureOption {
Expand Down
66 changes: 66 additions & 0 deletions taskfile/override.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
package taskfile

import (
"fmt"
"os"
"path/filepath"

"go.yaml.in/yaml/v3"

"github.com/go-task/task/v3/errors"
"github.com/go-task/task/v3/taskfile/ast"
)

var defaultOverrides = []string{
"Taskfile.override.yml",
"taskfile.override.yml",
"Taskfile.override.yaml",
"taskfile.override.yaml",
}

func (r *Reader) loadsOverride(node Node) bool {
_, ok := node.(*FileNode)
return ok && node.Parent() == nil && r.automaticOverride
}

func readAutomaticOverride(dir string, tf *ast.Taskfile) error {
for _, name := range defaultOverrides {
path := filepath.Join(dir, name)
// Check existence separately so a dangling symlink is an error too.
if _, err := os.Lstat(path); errors.Is(err, os.ErrNotExist) {
continue
} else if err != nil {
return fmt.Errorf("task: Failed to read Taskfile override: %w", err)
}
b, err := os.ReadFile(path)
if err != nil {
return fmt.Errorf("task: Failed to read Taskfile override: %w", err)
}
var override ast.Taskfile
if err := decodeTaskfile(b, path, &override, (*taskfileOverride)(&override)); err != nil {
return err
}
return tf.MergeOverride(&override)
}
return nil
}

// taskfileOverride restricts the root fields while using the normal Taskfile
// decoders for their values.
type taskfileOverride ast.Taskfile

func (tf *taskfileOverride) UnmarshalYAML(node *yaml.Node) error {
if node.Kind != yaml.MappingNode {
return errors.NewTaskfileDecodeError(nil, node).WithTypeMessage("taskfile override")
}
for i := 0; i < len(node.Content); i += 2 {
key := node.Content[i]
switch key.Value {
case "version", "output", "method", "includes", "set", "shopt", "vars", "env", "dotenv", "tasks", "run", "interval", "use_gitignore":
default:
return errors.NewTaskfileDecodeError(nil, key).
WithMessage("field %q is not allowed in a Taskfile override", key.Value)
}
}
return (*ast.Taskfile)(tf).UnmarshalYAML(node)
}
77 changes: 77 additions & 0 deletions taskfile/override_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
package taskfile

import (
"fmt"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"
"time"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

func writeOverrideFixture(t *testing.T, dir, name, content string) {
t.Helper()
require.NoError(t, os.WriteFile(filepath.Join(dir, name), []byte(content), 0o600))
}

// Settings have no visible effect on a task's output, so they are checked on
// the merged Taskfile.
func TestAutomaticOverrideSettings(t *testing.T) {
t.Parallel()
dir := t.TempDir()
writeOverrideFixture(t, dir, "Taskfile.yml", `version: '3'
method: timestamp
run: once
interval: 1s
set: [pipefail]
shopt: [globstar]
use_gitignore: true
`)
writeOverrideFixture(t, dir, "Taskfile.override.yml", `version: '3'
method: none
run: when_changed
interval: 2s
set: []
use_gitignore: false
`)
node, err := NewFileNode("", dir)
require.NoError(t, err)
graph, err := NewReader(WithAutomaticOverride(true)).Read(t.Context(), node)
require.NoError(t, err)
tf, err := graph.Merge()
require.NoError(t, err)
assert.Equal(t, "none", tf.Method)
assert.Equal(t, "when_changed", tf.Run)
assert.Equal(t, 2*time.Second, tf.Interval)
assert.Equal(t, []string{}, tf.Set)
assert.Equal(t, []string{"globstar"}, tf.Shopt)
assert.Equal(t, new(false), tf.UseGitignore)
}

func TestAutomaticOverrideRemote(t *testing.T) {
t.Parallel()
dir := t.TempDir()
writeOverrideFixture(t, dir, "Taskfile.override.yml", "invalid YAML: [")
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, _ = fmt.Fprint(w, "version: '3'\n")
}))
defer server.Close()
node, err := NewHTTPNode(server.URL+"/Taskfile.yml", dir, true)
require.NoError(t, err)
_, err = NewReader(WithAutomaticOverride(true), WithTempDir(t.TempDir())).Read(t.Context(), node)
require.NoError(t, err)
}

func TestAutomaticOverrideStdin(t *testing.T) { //nolint:paralleltest // replaces process-wide stdin
dir := t.TempDir()
writeOverrideFixture(t, dir, "Taskfile.override.yml", "invalid YAML: [")
replaceStdin(t, []byte("version: '3'\n"))
node, err := NewStdinNode(dir)
require.NoError(t, err)
_, err = NewReader(WithAutomaticOverride(true)).Read(t.Context(), node)
require.NoError(t, err)
}
Loading
Loading