From 3b27babc7ecd58d317bee0cae055d6f2b698f2be Mon Sep 17 00:00:00 2001 From: Lea Anthony Date: Sat, 9 Aug 2025 13:00:52 +1000 Subject: [PATCH 1/4] feat: overrides keyword MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Generated with Claude Code Co-Authored-By: Claude --- task_test.go | 178 +++++++++++++ taskfile/ast/graph.go | 35 ++- taskfile/ast/override.go | 212 ++++++++++++++++ taskfile/ast/taskfile.go | 94 +++++-- taskfile/ast/tasks.go | 37 +++ taskfile/reader.go | 82 ++++++ testdata/overrides/Taskfile.yml | 10 + testdata/overrides/overrides.yml | 6 + testdata/overrides_cycle/Taskfile.yml | 9 + testdata/overrides_cycle/one.yml | 9 + testdata/overrides_cycle/two.yml | 9 + .../Taskfile.with_default.yml | 9 + testdata/overrides_flatten/Taskfile.yml | 10 + .../overrides_flatten/included/Taskfile.yml | 9 + testdata/overrides_interpolation/Taskfile.yml | 12 + testdata/overrides_interpolation/override.yml | 6 + testdata/overrides_nested/Taskfile.yml | 12 + testdata/overrides_nested/level1.yml | 12 + testdata/overrides_nested/level2.yml | 9 + testdata/overrides_optional/Taskfile.yml | 14 ++ testdata/overrides_optional/existing.yml | 6 + testdata/overrides_with_includes/Taskfile.yml | 15 ++ testdata/overrides_with_includes/lib.yml | 6 + .../overrides_with_includes/override_lib.yml | 9 + testdata/overrides_with_vars/Taskfile.yml | 16 ++ testdata/overrides_with_vars/lib.yml | 6 + website/docs/reference/schema.mdx | 108 +++++--- website/docs/usage.mdx | 233 ++++++++++++++++++ website/static/next-schema.json | 73 ++++++ website/static/schema.json | 73 ++++++ 30 files changed, 1240 insertions(+), 79 deletions(-) create mode 100644 taskfile/ast/override.go create mode 100644 testdata/overrides/Taskfile.yml create mode 100644 testdata/overrides/overrides.yml create mode 100644 testdata/overrides_cycle/Taskfile.yml create mode 100644 testdata/overrides_cycle/one.yml create mode 100644 testdata/overrides_cycle/two.yml create mode 100644 testdata/overrides_flatten/Taskfile.with_default.yml create mode 100644 testdata/overrides_flatten/Taskfile.yml create mode 100644 testdata/overrides_flatten/included/Taskfile.yml create mode 100644 testdata/overrides_interpolation/Taskfile.yml create mode 100644 testdata/overrides_interpolation/override.yml create mode 100644 testdata/overrides_nested/Taskfile.yml create mode 100644 testdata/overrides_nested/level1.yml create mode 100644 testdata/overrides_nested/level2.yml create mode 100644 testdata/overrides_optional/Taskfile.yml create mode 100644 testdata/overrides_optional/existing.yml create mode 100644 testdata/overrides_with_includes/Taskfile.yml create mode 100644 testdata/overrides_with_includes/lib.yml create mode 100644 testdata/overrides_with_includes/override_lib.yml create mode 100644 testdata/overrides_with_vars/Taskfile.yml create mode 100644 testdata/overrides_with_vars/lib.yml diff --git a/task_test.go b/task_test.go index 7b986662cd..16bbf5289b 100644 --- a/task_test.go +++ b/task_test.go @@ -2566,6 +2566,184 @@ func TestWildcard(t *testing.T) { } } +func TestOverrides(t *testing.T) { + t.Parallel() + + t.Run("basic_override", func(t *testing.T) { + t.Parallel() + + var buff bytes.Buffer + e := task.NewExecutor( + task.WithDir("testdata/overrides"), + task.WithStdout(&buff), + task.WithStderr(&buff), + task.WithSilent(true), + ) + require.NoError(t, e.Setup()) + require.NoError(t, e.Run(context.Background(), &task.Call{Task: "greet"})) + assert.Equal(t, "Overridden!\n", buff.String()) + }) +} + +func TestOverridesFlatten(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + task string + expectedOutput string + }{ + {"overridden_task", "from_entrypoint", "overridden from included\n"}, + {"new_task_from_override", "from_included", "from included\n"}, + {"default_task_from_override", "default", "default from with_default\n"}, + {"new_task_from_with_default", "from_with_default", "from with_default\n"}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + + var buff bytes.Buffer + e := task.NewExecutor( + task.WithDir("testdata/overrides_flatten"), + task.WithStdout(&buff), + task.WithStderr(&buff), + task.WithSilent(true), + ) + require.NoError(t, e.Setup()) + require.NoError(t, e.Run(context.Background(), &task.Call{Task: test.task})) + assert.Equal(t, test.expectedOutput, buff.String()) + }) + } +} + +func TestOverridesNested(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + task string + expectedOutput string + }{ + {"base_task", "base", "base\n"}, + {"level1_task", "level1", "level1\n"}, + {"level2_task", "level2", "level2\n"}, + {"shared_task_final_override", "shared", "shared from level2 - final override\n"}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + + var buff bytes.Buffer + e := task.NewExecutor( + task.WithDir("testdata/overrides_nested"), + task.WithStdout(&buff), + task.WithStderr(&buff), + task.WithSilent(true), + ) + require.NoError(t, e.Setup()) + require.NoError(t, e.Run(context.Background(), &task.Call{Task: test.task})) + assert.Equal(t, test.expectedOutput, buff.String()) + }) + } +} + +func TestOverridesWithIncludes(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + task string + expectedOutput string + }{ + {"main_task", "main", "main task\n"}, + {"included_task", "lib:lib_task", "lib task\n"}, + {"overridden_shared_task", "shared", "shared from override - this should win\n"}, + {"new_task_from_override", "new_task", "new task from override\n"}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + + var buff bytes.Buffer + e := task.NewExecutor( + task.WithDir("testdata/overrides_with_includes"), + task.WithStdout(&buff), + task.WithStderr(&buff), + task.WithSilent(true), + ) + require.NoError(t, e.Setup()) + require.NoError(t, e.Run(context.Background(), &task.Call{Task: test.task})) + assert.Equal(t, test.expectedOutput, buff.String()) + }) + } +} + +func TestOverridesCycle(t *testing.T) { + t.Parallel() + + const dir = "testdata/overrides_cycle" + + var buff bytes.Buffer + e := task.NewExecutor( + task.WithDir(dir), + task.WithStdout(&buff), + task.WithStderr(&buff), + task.WithSilent(true), + ) + + err := e.Setup() + require.Error(t, err) + assert.Contains(t, err.Error(), "task: include cycle detected between") +} + +func TestOverridesOptional(t *testing.T) { + t.Parallel() + + var buff bytes.Buffer + e := task.NewExecutor( + task.WithDir("testdata/overrides_optional"), + task.WithStdout(&buff), + task.WithStderr(&buff), + task.WithSilent(true), + ) + require.NoError(t, e.Setup()) + require.NoError(t, e.Run(context.Background(), &task.Call{Task: "default"})) + assert.Equal(t, "overridden_from_existing\n", buff.String()) +} + +func TestOverridesWithVars(t *testing.T) { + t.Parallel() + + var buff bytes.Buffer + e := task.NewExecutor( + task.WithDir("testdata/overrides_with_vars"), + task.WithStdout(&buff), + task.WithStderr(&buff), + task.WithSilent(true), + ) + require.NoError(t, e.Setup()) + require.NoError(t, e.Run(context.Background(), &task.Call{Task: "test"})) + assert.Equal(t, "override_value-global\n", buff.String()) +} + +func TestOverridesInterpolation(t *testing.T) { + t.Parallel() + + var buff bytes.Buffer + e := task.NewExecutor( + task.WithDir("testdata/overrides_interpolation"), + task.WithStdout(&buff), + task.WithStderr(&buff), + task.WithSilent(true), + ) + require.NoError(t, e.Setup()) + require.NoError(t, e.Run(context.Background(), &task.Call{Task: "test"})) + assert.Equal(t, "interpolated override\n", buff.String()) +} + // enableExperimentForTest enables the experiment behind pointer e for the duration of test t and sub-tests, // with the experiment being restored to its previous state when tests complete. // diff --git a/taskfile/ast/graph.go b/taskfile/ast/graph.go index cb30093d88..2ead3fbf4f 100644 --- a/taskfile/ast/graph.go +++ b/taskfile/ast/graph.go @@ -81,20 +81,29 @@ func (tfg *TaskfileGraph) Merge() (*Taskfile, error) { return err } - // Get the merge options - includes, ok := edge.Properties.Data.([]*Include) - if !ok { - return fmt.Errorf("task: Failed to get merge options") - } - - // Merge the included Taskfiles into the parent Taskfile - for _, include := range includes { - if err := vertex.Taskfile.Merge( - includedVertex.Taskfile, - include, - ); err != nil { - return err + // Get the merge options - could be includes or overrides + if includes, ok := edge.Properties.Data.([]*Include); ok { + // Merge the included Taskfiles into the parent Taskfile + for _, include := range includes { + if err := vertex.Taskfile.Merge( + includedVertex.Taskfile, + include, + ); err != nil { + return err + } } + } else if overrides, ok := edge.Properties.Data.([]*Override); ok { + // Merge the overridden Taskfiles into the parent Taskfile + for _, override := range overrides { + if err := vertex.Taskfile.MergeOverride( + includedVertex.Taskfile, + override, + ); err != nil { + return err + } + } + } else { + return fmt.Errorf("task: Failed to get merge options") } return nil diff --git a/taskfile/ast/override.go b/taskfile/ast/override.go new file mode 100644 index 0000000000..aa0f2840e9 --- /dev/null +++ b/taskfile/ast/override.go @@ -0,0 +1,212 @@ +package ast + +import ( + "iter" + "sync" + + "github.com/elliotchance/orderedmap/v3" + "gopkg.in/yaml.v3" + + "github.com/go-task/task/v3/errors" + "github.com/go-task/task/v3/internal/deepcopy" +) + +type ( + // Override represents information about overridden taskfiles + Override struct { + Namespace string + Taskfile string + Dir string + Optional bool + Internal bool + Aliases []string + Excludes []string + AdvancedImport bool + Vars *Vars + Flatten bool + Checksum string + } + // Overrides is an ordered map of namespaces to overrides. + Overrides struct { + om *orderedmap.OrderedMap[string, *Override] + mutex sync.RWMutex + } + // An OverrideElement is a key-value pair that is used for initializing an + // Overrides structure. + OverrideElement orderedmap.Element[string, *Override] +) + +// NewOverrides creates a new instance of Overrides and initializes it with the +// provided set of elements, if any. The elements are added in the order they +// are passed. +func NewOverrides(els ...*OverrideElement) *Overrides { + overrides := &Overrides{ + om: orderedmap.NewOrderedMap[string, *Override](), + } + for _, el := range els { + overrides.Set(el.Key, el.Value) + } + return overrides +} + +// Len returns the number of overrides in the Overrides map. +func (overrides *Overrides) Len() int { + if overrides == nil || overrides.om == nil { + return 0 + } + defer overrides.mutex.RUnlock() + overrides.mutex.RLock() + return overrides.om.Len() +} + +// Get returns the value the the override with the provided key and a boolean +// that indicates if the value was found or not. If the value is not found, the +// returned override is a zero value and the bool is false. +func (overrides *Overrides) Get(key string) (*Override, bool) { + if overrides == nil || overrides.om == nil { + return &Override{}, false + } + defer overrides.mutex.RUnlock() + overrides.mutex.RLock() + return overrides.om.Get(key) +} + +// Set sets the value of the override with the provided key to the provided +// value. If the override already exists, its value is updated. If the override +// does not exist, it is created. +func (overrides *Overrides) Set(key string, value *Override) bool { + if overrides == nil { + overrides = NewOverrides() + } + if overrides.om == nil { + overrides.om = orderedmap.NewOrderedMap[string, *Override]() + } + defer overrides.mutex.Unlock() + overrides.mutex.Lock() + return overrides.om.Set(key, value) +} + +// All returns an iterator that loops over all task key-value pairs. +// Range calls the provided function for each override in the map. The function +// receives the override's key and value as arguments. If the function returns +// an error, the iteration stops and the error is returned. +func (overrides *Overrides) All() iter.Seq2[string, *Override] { + if overrides == nil || overrides.om == nil { + return func(yield func(string, *Override) bool) {} + } + return overrides.om.AllFromFront() +} + +// Keys returns an iterator that loops over all task keys. +func (overrides *Overrides) Keys() iter.Seq[string] { + if overrides == nil || overrides.om == nil { + return func(yield func(string) bool) {} + } + return overrides.om.Keys() +} + +// Values returns an iterator that loops over all task values. +func (overrides *Overrides) Values() iter.Seq[*Override] { + if overrides == nil || overrides.om == nil { + return func(yield func(*Override) bool) {} + } + return overrides.om.Values() +} + +// UnmarshalYAML implements the yaml.Unmarshaler interface. +func (overrides *Overrides) UnmarshalYAML(node *yaml.Node) error { + if overrides == nil || overrides.om == nil { + *overrides = *NewOverrides() + } + switch node.Kind { + case yaml.MappingNode: + // NOTE: orderedmap does not have an unmarshaler, so we have to decode + // the map manually. We increment over 2 values at a time and assign + // them as a key-value pair. + for i := 0; i < len(node.Content); i += 2 { + keyNode := node.Content[i] + valueNode := node.Content[i+1] + + // Decode the value node into an Override struct + var v Override + if err := valueNode.Decode(&v); err != nil { + return errors.NewTaskfileDecodeError(err, node) + } + + // Set the override namespace + v.Namespace = keyNode.Value + + // Add the override to the ordered map + overrides.Set(keyNode.Value, &v) + } + return nil + } + + return errors.NewTaskfileDecodeError(nil, node).WithTypeMessage("overrides") +} + +func (override *Override) UnmarshalYAML(node *yaml.Node) error { + switch node.Kind { + + case yaml.ScalarNode: + var str string + if err := node.Decode(&str); err != nil { + return errors.NewTaskfileDecodeError(err, node) + } + override.Taskfile = str + // Overrides always flatten automatically + override.Flatten = true + return nil + + case yaml.MappingNode: + var overrideTaskfile struct { + Taskfile string + Dir string + Optional bool + Internal bool + Flatten bool + Aliases []string + Excludes []string + Vars *Vars + Checksum string + } + if err := node.Decode(&overrideTaskfile); err != nil { + return errors.NewTaskfileDecodeError(err, node) + } + override.Taskfile = overrideTaskfile.Taskfile + override.Dir = overrideTaskfile.Dir + override.Optional = overrideTaskfile.Optional + override.Internal = overrideTaskfile.Internal + override.Aliases = overrideTaskfile.Aliases + override.Excludes = overrideTaskfile.Excludes + override.AdvancedImport = true + override.Vars = overrideTaskfile.Vars + // Overrides always flatten automatically, ignore the flatten setting from YAML + override.Flatten = true + override.Checksum = overrideTaskfile.Checksum + return nil + } + + return errors.NewTaskfileDecodeError(nil, node).WithTypeMessage("override") +} + +// DeepCopy creates a new instance of OverriddenTaskfile and copies +// data by value from the source struct. +func (override *Override) DeepCopy() *Override { + if override == nil { + return nil + } + return &Override{ + Namespace: override.Namespace, + Taskfile: override.Taskfile, + Dir: override.Dir, + Optional: override.Optional, + Internal: override.Internal, + Excludes: deepcopy.Slice(override.Excludes), + AdvancedImport: override.AdvancedImport, + Vars: override.Vars.DeepCopy(), + Flatten: override.Flatten, + Aliases: deepcopy.Slice(override.Aliases), + Checksum: override.Checksum, + } +} \ No newline at end of file diff --git a/taskfile/ast/taskfile.go b/taskfile/ast/taskfile.go index 4aad932da7..cb8e2f9042 100644 --- a/taskfile/ast/taskfile.go +++ b/taskfile/ast/taskfile.go @@ -20,20 +20,21 @@ var ErrIncludedTaskfilesCantHaveDotenvs = errors.New("task: Included Taskfiles c // Taskfile is the abstract syntax tree for a Taskfile type Taskfile struct { - Location string - Version *semver.Version - Output Output - Method string - Includes *Includes - Set []string - Shopt []string - Vars *Vars - Env *Vars - Tasks *Tasks - Silent bool - Dotenv []string - Run string - Interval time.Duration + Location string + Version *semver.Version + Output Output + Method string + Includes *Includes + Overrides *Overrides + Set []string + Shopt []string + Vars *Vars + Env *Vars + Tasks *Tasks + Silent bool + Dotenv []string + Run string + Interval time.Duration } // Merge merges the second Taskfile into the first @@ -50,6 +51,9 @@ func (t1 *Taskfile) Merge(t2 *Taskfile, include *Include) error { if t1.Includes == nil { t1.Includes = NewIncludes() } + if t1.Overrides == nil { + t1.Overrides = NewOverrides() + } if t1.Vars == nil { t1.Vars = NewVars() } @@ -64,23 +68,55 @@ func (t1 *Taskfile) Merge(t2 *Taskfile, include *Include) error { return t1.Tasks.Merge(t2.Tasks, include, t1.Vars) } +// MergeOverride merges the second Taskfile into the first using override semantics +func (t1 *Taskfile) MergeOverride(t2 *Taskfile, override *Override) error { + if !t1.Version.Equal(t2.Version) { + return fmt.Errorf(`task: Taskfiles versions should match. First is "%s" but second is "%s"`, t1.Version, t2.Version) + } + if len(t2.Dotenv) > 0 { + return ErrIncludedTaskfilesCantHaveDotenvs + } + if t2.Output.IsSet() { + t1.Output = t2.Output + } + if t1.Includes == nil { + t1.Includes = NewIncludes() + } + if t1.Overrides == nil { + t1.Overrides = NewOverrides() + } + if t1.Vars == nil { + t1.Vars = NewVars() + } + if t1.Env == nil { + t1.Env = NewVars() + } + if t1.Tasks == nil { + t1.Tasks = NewTasks() + } + t1.Vars.Merge(t2.Vars, nil) + t1.Env.Merge(t2.Env, nil) + return t1.Tasks.MergeOverride(t2.Tasks, override, t1.Vars) +} + func (tf *Taskfile) UnmarshalYAML(node *yaml.Node) error { switch node.Kind { case yaml.MappingNode: var taskfile struct { - Version *semver.Version - Output Output - Method string - Includes *Includes - Set []string - Shopt []string - Vars *Vars - Env *Vars - Tasks *Tasks - Silent bool - Dotenv []string - Run string - Interval time.Duration + Version *semver.Version + Output Output + Method string + Includes *Includes + Overrides *Overrides + Set []string + Shopt []string + Vars *Vars + Env *Vars + Tasks *Tasks + Silent bool + Dotenv []string + Run string + Interval time.Duration } if err := node.Decode(&taskfile); err != nil { return errors.NewTaskfileDecodeError(err, node) @@ -89,6 +125,7 @@ func (tf *Taskfile) UnmarshalYAML(node *yaml.Node) error { tf.Output = taskfile.Output tf.Method = taskfile.Method tf.Includes = taskfile.Includes + tf.Overrides = taskfile.Overrides tf.Set = taskfile.Set tf.Shopt = taskfile.Shopt tf.Vars = taskfile.Vars @@ -101,6 +138,9 @@ func (tf *Taskfile) UnmarshalYAML(node *yaml.Node) error { if tf.Includes == nil { tf.Includes = NewIncludes() } + if tf.Overrides == nil { + tf.Overrides = NewOverrides() + } if tf.Vars == nil { tf.Vars = NewVars() } diff --git a/taskfile/ast/tasks.go b/taskfile/ast/tasks.go index dd499b85c7..58d6391e39 100644 --- a/taskfile/ast/tasks.go +++ b/taskfile/ast/tasks.go @@ -208,6 +208,43 @@ func (t1 *Tasks) Merge(t2 *Tasks, include *Include, includedTaskfileVars *Vars) return nil } +func (t1 *Tasks) MergeOverride(t2 *Tasks, override *Override, includedTaskfileVars *Vars) error { + defer t2.mutex.RUnlock() + t2.mutex.RLock() + for name, v := range t2.All(nil) { + // We do a deep copy of the task struct here to ensure that no data can + // be changed elsewhere once the taskfile is merged. + task := v.DeepCopy() + // Set the task to internal if EITHER the overridden task or the overridden + // taskfile are marked as internal + task.Internal = task.Internal || (override != nil && override.Internal) + taskName := name + + // if the task is in the exclude list, don't add it to the merged taskfile + if slices.Contains(override.Excludes, name) { + continue + } + + // Overrides always flatten, so we don't need the namespace logic + // but we still need to handle variables and directory resolution + + if override.AdvancedImport { + task.Dir = filepathext.SmartJoin(override.Dir, task.Dir) + if task.IncludeVars == nil { + task.IncludeVars = NewVars() + } + task.IncludeVars.Merge(override.Vars, nil) + task.IncludedTaskfileVars = includedTaskfileVars.DeepCopy() + } + + // For overrides, we simply replace any existing task instead of erroring + // This is the key difference from includes + t1.Set(taskName, task) + } + + return nil +} + func (t *Tasks) UnmarshalYAML(node *yaml.Node) error { if t == nil || t.om == nil { *t = *NewTasks() diff --git a/taskfile/reader.go b/taskfile/reader.go index 3f36ad62b2..04fabfcc39 100644 --- a/taskfile/reader.go +++ b/taskfile/reader.go @@ -314,6 +314,88 @@ func (r *Reader) include(ctx context.Context, node Node) error { }) } + // Loop over each overridden taskfile + for _, override := range vertex.Taskfile.Overrides.All() { + vars := env.GetEnviron() + vars.Merge(vertex.Taskfile.Vars, nil) + // Start a goroutine to process each overridden Taskfile + g.Go(func() error { + cache := &templater.Cache{Vars: vars} + override = &ast.Override{ + Namespace: override.Namespace, + Taskfile: templater.Replace(override.Taskfile, cache), + Dir: templater.Replace(override.Dir, cache), + Optional: override.Optional, + Internal: override.Internal, + Flatten: override.Flatten, + Aliases: override.Aliases, + AdvancedImport: override.AdvancedImport, + Excludes: override.Excludes, + Vars: override.Vars, + Checksum: override.Checksum, + } + if err := cache.Err(); err != nil { + return err + } + + entrypoint, err := node.ResolveEntrypoint(override.Taskfile) + if err != nil { + return err + } + + override.Dir, err = node.ResolveDir(override.Dir) + if err != nil { + return err + } + + overrideNode, err := NewNode(entrypoint, override.Dir, r.insecure, + WithParent(node), + WithChecksum(override.Checksum), + ) + if err != nil { + if override.Optional { + return nil + } + return err + } + + // Recurse into the overridden Taskfile + if err := r.include(ctx, overrideNode); err != nil { + return err + } + + // Create an edge between the Taskfiles + r.graph.Lock() + defer r.graph.Unlock() + edge, err := r.graph.Edge(node.Location(), overrideNode.Location()) + if err == graph.ErrEdgeNotFound { + // If the edge doesn't exist, create it + err = r.graph.AddEdge( + node.Location(), + overrideNode.Location(), + graph.EdgeData([]*ast.Override{override}), + graph.EdgeWeight(1), + ) + } else { + // If the edge already exists + edgeData := append(edge.Properties.Data.([]*ast.Override), override) + err = r.graph.UpdateEdge( + node.Location(), + overrideNode.Location(), + graph.EdgeData(edgeData), + graph.EdgeWeight(len(edgeData)), + ) + } + if errors.Is(err, graph.ErrEdgeCreatesCycle) { + return errors.TaskfileCycleError{ + Source: node.Location(), + Destination: overrideNode.Location(), + } + } + return err + }) + } + // Wait for all the go routines to finish return g.Wait() } diff --git a/testdata/overrides/Taskfile.yml b/testdata/overrides/Taskfile.yml new file mode 100644 index 0000000000..7969789661 --- /dev/null +++ b/testdata/overrides/Taskfile.yml @@ -0,0 +1,10 @@ +version: '3' + +overrides: + lib: + taskfile: ./overrides.yml + +tasks: + greet: + cmds: + - echo "Greet" \ No newline at end of file diff --git a/testdata/overrides/overrides.yml b/testdata/overrides/overrides.yml new file mode 100644 index 0000000000..c56123f768 --- /dev/null +++ b/testdata/overrides/overrides.yml @@ -0,0 +1,6 @@ +version: '3' + +tasks: + greet: + cmds: + - echo "Overridden!" \ No newline at end of file diff --git a/testdata/overrides_cycle/Taskfile.yml b/testdata/overrides_cycle/Taskfile.yml new file mode 100644 index 0000000000..5095d785ad --- /dev/null +++ b/testdata/overrides_cycle/Taskfile.yml @@ -0,0 +1,9 @@ +version: '3' + +overrides: + one: ./one.yml + +tasks: + default: + cmds: + - echo "default" \ No newline at end of file diff --git a/testdata/overrides_cycle/one.yml b/testdata/overrides_cycle/one.yml new file mode 100644 index 0000000000..3da52dab37 --- /dev/null +++ b/testdata/overrides_cycle/one.yml @@ -0,0 +1,9 @@ +version: '3' + +overrides: + two: ./two.yml + +tasks: + one: + cmds: + - echo "one" \ No newline at end of file diff --git a/testdata/overrides_cycle/two.yml b/testdata/overrides_cycle/two.yml new file mode 100644 index 0000000000..c29d6b6679 --- /dev/null +++ b/testdata/overrides_cycle/two.yml @@ -0,0 +1,9 @@ +version: '3' + +overrides: + one: ./one.yml + +tasks: + two: + cmds: + - echo "two" \ No newline at end of file diff --git a/testdata/overrides_flatten/Taskfile.with_default.yml b/testdata/overrides_flatten/Taskfile.with_default.yml new file mode 100644 index 0000000000..30637b3760 --- /dev/null +++ b/testdata/overrides_flatten/Taskfile.with_default.yml @@ -0,0 +1,9 @@ +version: '3' + +tasks: + default: + cmds: + - echo "default from with_default" + from_with_default: + cmds: + - echo "from with_default" \ No newline at end of file diff --git a/testdata/overrides_flatten/Taskfile.yml b/testdata/overrides_flatten/Taskfile.yml new file mode 100644 index 0000000000..98896d5c6f --- /dev/null +++ b/testdata/overrides_flatten/Taskfile.yml @@ -0,0 +1,10 @@ +version: '3' + +overrides: + included: + taskfile: ./included/Taskfile.yml + dir: ./included + with_default: ./Taskfile.with_default.yml + +tasks: + from_entrypoint: echo "from entrypoint" \ No newline at end of file diff --git a/testdata/overrides_flatten/included/Taskfile.yml b/testdata/overrides_flatten/included/Taskfile.yml new file mode 100644 index 0000000000..9c9d6803c3 --- /dev/null +++ b/testdata/overrides_flatten/included/Taskfile.yml @@ -0,0 +1,9 @@ +version: '3' + +tasks: + from_entrypoint: + cmds: + - echo "overridden from included" + from_included: + cmds: + - echo "from included" \ No newline at end of file diff --git a/testdata/overrides_interpolation/Taskfile.yml b/testdata/overrides_interpolation/Taskfile.yml new file mode 100644 index 0000000000..13fc0e93ab --- /dev/null +++ b/testdata/overrides_interpolation/Taskfile.yml @@ -0,0 +1,12 @@ +version: '3' + +vars: + FILE: "override" + +overrides: + lib: ./{{.FILE}}.yml + +tasks: + test: + cmds: + - echo "base" \ No newline at end of file diff --git a/testdata/overrides_interpolation/override.yml b/testdata/overrides_interpolation/override.yml new file mode 100644 index 0000000000..9ed83f6d72 --- /dev/null +++ b/testdata/overrides_interpolation/override.yml @@ -0,0 +1,6 @@ +version: '3' + +tasks: + test: + cmds: + - echo "interpolated override" \ No newline at end of file diff --git a/testdata/overrides_nested/Taskfile.yml b/testdata/overrides_nested/Taskfile.yml new file mode 100644 index 0000000000..9e132391a6 --- /dev/null +++ b/testdata/overrides_nested/Taskfile.yml @@ -0,0 +1,12 @@ +version: '3' + +overrides: + level1: ./level1.yml + +tasks: + base: + cmds: + - echo "base" + shared: + cmds: + - echo "shared from base" \ No newline at end of file diff --git a/testdata/overrides_nested/level1.yml b/testdata/overrides_nested/level1.yml new file mode 100644 index 0000000000..baef68625a --- /dev/null +++ b/testdata/overrides_nested/level1.yml @@ -0,0 +1,12 @@ +version: '3' + +overrides: + level2: ./level2.yml + +tasks: + level1: + cmds: + - echo "level1" + shared: + cmds: + - echo "shared from level1" \ No newline at end of file diff --git a/testdata/overrides_nested/level2.yml b/testdata/overrides_nested/level2.yml new file mode 100644 index 0000000000..0b25556518 --- /dev/null +++ b/testdata/overrides_nested/level2.yml @@ -0,0 +1,9 @@ +version: '3' + +tasks: + level2: + cmds: + - echo "level2" + shared: + cmds: + - echo "shared from level2 - final override" \ No newline at end of file diff --git a/testdata/overrides_optional/Taskfile.yml b/testdata/overrides_optional/Taskfile.yml new file mode 100644 index 0000000000..8e5548014d --- /dev/null +++ b/testdata/overrides_optional/Taskfile.yml @@ -0,0 +1,14 @@ +version: '3' + +overrides: + existing: + taskfile: ./existing.yml + optional: true + missing: + taskfile: ./missing.yml + optional: true + +tasks: + default: + cmds: + - echo "called_dep" \ No newline at end of file diff --git a/testdata/overrides_optional/existing.yml b/testdata/overrides_optional/existing.yml new file mode 100644 index 0000000000..1932e0e5e0 --- /dev/null +++ b/testdata/overrides_optional/existing.yml @@ -0,0 +1,6 @@ +version: '3' + +tasks: + default: + cmds: + - echo "overridden_from_existing" \ No newline at end of file diff --git a/testdata/overrides_with_includes/Taskfile.yml b/testdata/overrides_with_includes/Taskfile.yml new file mode 100644 index 0000000000..90ec5f5727 --- /dev/null +++ b/testdata/overrides_with_includes/Taskfile.yml @@ -0,0 +1,15 @@ +version: '3' + +includes: + lib: ./lib.yml + +overrides: + override_lib: ./override_lib.yml + +tasks: + main: + cmds: + - echo "main task" + shared: + cmds: + - echo "shared from main" \ No newline at end of file diff --git a/testdata/overrides_with_includes/lib.yml b/testdata/overrides_with_includes/lib.yml new file mode 100644 index 0000000000..406bf2b279 --- /dev/null +++ b/testdata/overrides_with_includes/lib.yml @@ -0,0 +1,6 @@ +version: '3' + +tasks: + lib_task: + cmds: + - echo "lib task" \ No newline at end of file diff --git a/testdata/overrides_with_includes/override_lib.yml b/testdata/overrides_with_includes/override_lib.yml new file mode 100644 index 0000000000..7c318077b5 --- /dev/null +++ b/testdata/overrides_with_includes/override_lib.yml @@ -0,0 +1,9 @@ +version: '3' + +tasks: + shared: + cmds: + - echo "shared from override - this should win" + new_task: + cmds: + - echo "new task from override" \ No newline at end of file diff --git a/testdata/overrides_with_vars/Taskfile.yml b/testdata/overrides_with_vars/Taskfile.yml new file mode 100644 index 0000000000..183b5da07e --- /dev/null +++ b/testdata/overrides_with_vars/Taskfile.yml @@ -0,0 +1,16 @@ +version: '3' + +vars: + GLOBAL_VAR: "global" + +overrides: + lib: + taskfile: ./lib.yml + vars: + OVERRIDE_VAR: "override_value" + GLOBAL_VAR: "overridden_global" + +tasks: + test: + cmds: + - echo "{{.GLOBAL_VAR}}" \ No newline at end of file diff --git a/testdata/overrides_with_vars/lib.yml b/testdata/overrides_with_vars/lib.yml new file mode 100644 index 0000000000..759ce66643 --- /dev/null +++ b/testdata/overrides_with_vars/lib.yml @@ -0,0 +1,6 @@ +version: '3' + +tasks: + test: + cmds: + - echo "{{.OVERRIDE_VAR}}-{{.GLOBAL_VAR}}" \ No newline at end of file diff --git a/website/docs/reference/schema.mdx b/website/docs/reference/schema.mdx index 8339f5100e..a1e6d16621 100644 --- a/website/docs/reference/schema.mdx +++ b/website/docs/reference/schema.mdx @@ -7,21 +7,22 @@ toc_max_heading_level: 5 # Schema Reference -| Attribute | Type | Default | Description | -|------------|------------------------------------|---------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| `version` | `string` | | Version of the Taskfile. The current version is `3`. | -| `output` | `string` | `interleaved` | Output mode. Available options: `interleaved`, `group` and `prefixed`. | -| `method` | `string` | `checksum` | Default method in this Taskfile. Can be overridden in a task by task basis. Available options: `checksum`, `timestamp` and `none`. | -| `includes` | [`map[string]Include`](#include) | | Additional Taskfiles to be included. | -| `vars` | [`map[string]Variable`](#variable) | | A set of global variables. | -| `env` | [`map[string]Variable`](#variable) | | A set of global environment variables. | -| `tasks` | [`map[string]Task`](#task) | | A set of task definitions. | -| `silent` | `bool` | `false` | Default 'silent' options for this Taskfile. If `false`, can be overridden with `true` in a task by task basis. | -| `dotenv` | `[]string` | | A list of `.env` file paths to be parsed. | -| `run` | `string` | `always` | Default 'run' option for this Taskfile. Available options: `always`, `once` and `when_changed`. | -| `interval` | `string` | `5s` | Sets a different watch interval when using `--watch`, the default being 5 seconds. This string should be a valid [Go Duration](https://pkg.go.dev/time#ParseDuration). | -| `set` | `[]string` | | Specify options for the [`set` builtin](https://www.gnu.org/software/bash/manual/html_node/The-Set-Builtin.html). | -| `shopt` | `[]string` | | Specify option for the [`shopt` builtin](https://www.gnu.org/software/bash/manual/html_node/The-Shopt-Builtin.html). | +| Attribute | Type | Default | Description | +|-------------|------------------------------------|---------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `version` | `string` | | Version of the Taskfile. The current version is `3`. | +| `output` | `string` | `interleaved` | Output mode. Available options: `interleaved`, `group` and `prefixed`. | +| `method` | `string` | `checksum` | Default method in this Taskfile. Can be overridden in a task by task basis. Available options: `checksum`, `timestamp` and `none`. | +| `includes` | [`map[string]Include`](#include) | | Additional Taskfiles to be included. | +| `overrides` | [`map[string]Override`](#override) | | Additional Taskfiles to be included with override semantics (automatic flattening and task replacement). | +| `vars` | [`map[string]Variable`](#variable) | | A set of global variables. | +| `env` | [`map[string]Variable`](#variable) | | A set of global environment variables. | +| `tasks` | [`map[string]Task`](#task) | | A set of task definitions. | +| `silent` | `bool` | `false` | Default 'silent' options for this Taskfile. If `false`, can be overridden with `true` in a task by task basis. | +| `dotenv` | `[]string` | | A list of `.env` file paths to be parsed. | +| `run` | `string` | `always` | Default 'run' option for this Taskfile. Available options: `always`, `once` and `when_changed`. | +| `interval` | `string` | `5s` | Sets a different watch interval when using `--watch`, the default being 5 seconds. This string should be a valid [Go Duration](https://pkg.go.dev/time#ParseDuration). | +| `set` | `[]string` | | Specify options for the [`set` builtin](https://www.gnu.org/software/bash/manual/html_node/The-Set-Builtin.html). | +| `shopt` | `[]string` | | Specify option for the [`shopt` builtin](https://www.gnu.org/software/bash/manual/html_node/The-Shopt-Builtin.html). | ## Include @@ -38,8 +39,7 @@ toc_max_heading_level: 5 :::info -Informing only a string like below is equivalent to setting that value to the -`taskfile` attribute. +Informing only a string like below is equivalent to setting that value to the `taskfile` attribute. ```yaml includes: @@ -48,10 +48,40 @@ includes: ::: +## Override + +| Attribute | Type | Default | Description | +|------------|-----------------------|-------------------------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `taskfile` | `string` | | The path for the Taskfile or directory to be overridden. If a directory, Task will look for files named `Taskfile.yml` or `Taskfile.yaml` inside that directory. If a relative path, resolved relative to the directory containing the overriding Taskfile. | +| `dir` | `string` | The parent Taskfile directory | The working directory of the overridden tasks when run. | +| `optional` | `bool` | `false` | If `true`, no errors will be thrown if the specified file does not exist. | +| `flatten` | `bool` | `true` | Always `true` for overrides. Tasks from the overridden Taskfile are automatically flattened into the overriding Taskfile. If a task with the same name exists, the overridden version replaces the original. | +| `internal` | `bool` | `false` | Stops any task in the overridden Taskfile from being callable on the command line. These commands will also be omitted from the output when used with `--list`. | +| `aliases` | `[]string` | | Alternative names for the namespace of the overridden Taskfile (mainly for organizational purposes since overrides are flattened). | +| `vars` | `map[string]Variable` | | A set of variables to apply to the overridden Taskfile. | +| `excludes` | `[]string` | | A list of task names to exclude from being overridden. | +| `checksum` | `string` | | The checksum of the file you expect to override. If the checksum does not match, the file will not be overridden. | + +:::info + +Overrides work similarly to includes but with automatic flattening and task replacement behavior: +- Tasks are always flattened into the local namespace (no namespace prefix needed) +- Duplicate task names replace existing tasks instead of causing errors +- Later overrides take precedence over earlier ones + +Informing only a string like below is equivalent to setting that value to the `taskfile` attribute. + +```yaml +overrides: + lib: ./path +``` + +::: + ## Variable | Attribute | Type | Default | Description | -| --------- | -------- | ------- | ------------------------------------------------------------------------ | +|-----------|----------|---------|--------------------------------------------------------------------------| | _itself_ | `string` | | A static value that will be set to the variable. | | `sh` | `string` | | A shell command. The output (`STDOUT`) will be assigned to the variable. | @@ -84,7 +114,7 @@ vars: ## Task | Attribute | Type | Default | Description | -| --------------- | ---------------------------------- | ----------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +|-----------------|------------------------------------|-------------------------------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| | `cmds` | [`[]Command`](#command) | | A list of shell commands to be executed. | | `deps` | [`[]Dependency`](#dependency) | | A list of dependencies of this task. Tasks defined here will run in parallel before this task. | | `label` | `string` | | Overrides the name of the task in the output when a task is run. Supports variables. | @@ -108,7 +138,7 @@ vars: | `prefix` | `string` | | Defines a string to prefix the output of tasks running in parallel. Only used when the output mode is `prefixed`. | | `ignore_error` | `bool` | `false` | Continue execution if errors happen while executing commands. | | `run` | `string` | The one declared globally in the Taskfile or `always` | Specifies whether the task should run again or not if called more than once. Available options: `always`, `once` and `when_changed`. | -| `platforms` | `[]string` | All platforms | Specifies which platforms the task should be run on. [Valid GOOS and GOARCH values allowed](https://github.com/golang/go/blob/master/src/internal/syslist/syslist.go). Task will be skipped otherwise. | +| `platforms` | `[]string` | All platforms | Specifies which platforms the task should be run on. [Valid GOOS and GOARCH values allowed](https://github.com/golang/go/blob/master/src/internal/syslist/syslist.go). Task will be skipped otherwise. | | `set` | `[]string` | | Specify options for the [`set` builtin](https://www.gnu.org/software/bash/manual/html_node/The-Set-Builtin.html). | | `shopt` | `[]string` | | Specify option for the [`shopt` builtin](https://www.gnu.org/software/bash/manual/html_node/The-Shopt-Builtin.html). | @@ -133,18 +163,18 @@ tasks: ### Command -| Attribute | Type | Default | Description | -| -------------- | ---------------------------------- | ------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `cmd` | `string` | | The shell command to be executed. | -| `task` | `string` | | Set this to trigger execution of another task instead of running a command. This cannot be set together with `cmd`. | -| `for` | [`For`](#for) | | Runs the command once for each given value. | -| `silent` | `bool` | `false` | Skips some output for this command. Note that STDOUT and STDERR of the commands will still be redirected. | -| `vars` | [`map[string]Variable`](#variable) | | Optional additional variables to be passed to the referenced task. Only relevant when setting `task` instead of `cmd`. | -| `ignore_error` | `bool` | `false` | Continue execution if errors happen while executing the command. | -| `defer` | [`Defer`](#defer) | | Alternative to `cmd`, but schedules the command or a task to be executed at the end of this task instead of immediately. This cannot be used together with `cmd`. | +| Attribute | Type | Default | Description | +|----------------|------------------------------------|---------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `cmd` | `string` | | The shell command to be executed. | +| `task` | `string` | | Set this to trigger execution of another task instead of running a command. This cannot be set together with `cmd`. | +| `for` | [`For`](#for) | | Runs the command once for each given value. | +| `silent` | `bool` | `false` | Skips some output for this command. Note that STDOUT and STDERR of the commands will still be redirected. | +| `vars` | [`map[string]Variable`](#variable) | | Optional additional variables to be passed to the referenced task. Only relevant when setting `task` instead of `cmd`. | +| `ignore_error` | `bool` | `false` | Continue execution if errors happen while executing the command. | +| `defer` | [`Defer`](#defer) | | Alternative to `cmd`, but schedules the command or a task to be executed at the end of this task instead of immediately. This cannot be used together with `cmd`. | | `platforms` | `[]string` | All platforms | Specifies which platforms the command should be run on. [Valid GOOS and GOARCH values allowed](https://github.com/golang/go/blob/master/src/internal/syslist/syslist.go). Command will be skipped otherwise. | -| `set` | `[]string` | | Specify options for the [`set` builtin](https://www.gnu.org/software/bash/manual/html_node/The-Set-Builtin.html). | -| `shopt` | `[]string` | | Specify option for the [`shopt` builtin](https://www.gnu.org/software/bash/manual/html_node/The-Shopt-Builtin.html). | +| `set` | `[]string` | | Specify options for the [`set` builtin](https://www.gnu.org/software/bash/manual/html_node/The-Set-Builtin.html). | +| `shopt` | `[]string` | | Specify option for the [`shopt` builtin](https://www.gnu.org/software/bash/manual/html_node/The-Shopt-Builtin.html). | :::info @@ -163,7 +193,7 @@ tasks: ### Dependency | Attribute | Type | Default | Description | -| --------- | ---------------------------------- | ------- | ---------------------------------------------------------------------------------------------------------------- | +|-----------|------------------------------------|---------|------------------------------------------------------------------------------------------------------------------| | `task` | `string` | | The task to be execute as a dependency. | | `vars` | [`map[string]Variable`](#variable) | | Optional additional variables to be passed to this task. | | `silent` | `bool` | `false` | Hides task name and command from output. The command's output will still be redirected to `STDOUT` and `STDERR`. | @@ -186,10 +216,10 @@ tasks: The `defer` parameter defines a shell command to run, or a task to trigger, at the end of the current task instead of immediately. If defined as a string this is a shell command, otherwise it is a map defining a task to call: -| Attribute | Type | Default | Description | -| --------- | ---------------------------------- | ------- | ----------------------------------------------------------------- | -| `task` | `string` | | The deferred task to trigger. | -| `vars` | [`map[string]Variable`](#variable) | | Optional additional variables to be passed to the deferred task. | +| Attribute | Type | Default | Description | +|-----------|------------------------------------|---------|------------------------------------------------------------------------------------------------------------------| +| `task` | `string` | | The deferred task to trigger. | +| `vars` | [`map[string]Variable`](#variable) | | Optional additional variables to be passed to the deferred task. | | `silent` | `bool` | `false` | Hides task name and command from output. The command's output will still be redirected to `STDOUT` and `STDERR`. | ### For @@ -210,7 +240,7 @@ Finally, the `for` parameter can be defined as a map when you want to use a variable to define the values to loop over: | Attribute | Type | Default | Description | -| --------- | -------- | ---------------- | -------------------------------------------- | +|-----------|----------|------------------|----------------------------------------------| | `var` | `string` | | The name of the variable to use as an input. | | `split` | `string` | (any whitespace) | What string the variable should be split on. | | `as` | `string` | `ITEM` | The name of the iterator variable. | @@ -218,7 +248,7 @@ variable to define the values to loop over: ### Precondition | Attribute | Type | Default | Description | -| --------- | -------- | ------- | ------------------------------------------------------------------------------------------------------------ | +|-----------|----------|---------|--------------------------------------------------------------------------------------------------------------| | `sh` | `string` | | Command to be executed. If a non-zero exit code is returned, the task errors without executing its commands. | | `msg` | `string` | | Optional message to print if the precondition isn't met. | @@ -238,5 +268,5 @@ tasks: ### Requires | Attribute | Type | Default | Description | -| --------- | ---------- | ------- | -------------------------------------------------------------------------------------------------- | +|-----------|------------|---------|----------------------------------------------------------------------------------------------------| | `vars` | `[]string` | | List of variable or environment variable names that must be set if this task is to execute and run | diff --git a/website/docs/usage.mdx b/website/docs/usage.mdx index 9590cf4f25..e2ff49fc05 100644 --- a/website/docs/usage.mdx +++ b/website/docs/usage.mdx @@ -480,6 +480,239 @@ overridable, use the ::: +## Overriding tasks from other Taskfiles + +Task supports overriding tasks from other Taskfiles using the `overrides` keyword. +This is similar to includes, but with a key difference: instead of erroring when +duplicate task names are found, overrides will replace existing tasks with the +overridden version. Overrides are automatically flattened into the local namespace. + +```yaml +version: '3' + +overrides: + lib: + taskfile: ./overrides.yml + +tasks: + greet: + cmds: + - echo "Original" +``` + +If `./overrides.yml` contains a task named `greet`, it will replace the original +`greet` task in the main Taskfile. + + + + +```yaml +version: '3' + +overrides: + lib: + taskfile: ./overrides.yml + +tasks: + greet: + cmds: + - echo "Original" +``` + + + + +```yaml +version: '3' + +tasks: + greet: + cmds: + - echo "Overridden!" + new_task: + cmds: + - echo "New task from override" +``` + + + + +Running `task greet` will output "Overridden!" instead of "Original". The +`new_task` will also be available directly without any namespace prefix. + +### Key differences from includes + +- **Automatic flattening**: Overrides are always flattened into the local namespace +- **Task replacement**: Duplicate task names replace existing tasks instead of causing errors +- **Order matters**: Later overrides take precedence over earlier ones + +### Nested overrides + +Overrides can be nested multiple levels deep, with the final override taking precedence: + + + + +```yaml +version: '3' + +overrides: + level1: ./level1.yml + +tasks: + shared: + cmds: + - echo "base" +``` + + + + +```yaml +version: '3' + +overrides: + level2: ./level2.yml + +tasks: + shared: + cmds: + - echo "level1" +``` + + + + +```yaml +version: '3' + +tasks: + shared: + cmds: + - echo "level2 - final" +``` + + + + +Running `task shared` will output "level2 - final" as it's the final override in the chain. + +### Optional overrides + +Like includes, overrides can be marked as optional: + +```yaml +version: '3' + +overrides: + optional_lib: + taskfile: ./optional_overrides.yml + optional: true + +tasks: + greet: + cmds: + - echo "This will work even if ./optional_overrides.yml doesn't exist" +``` + +### Internal overrides + +Overrides marked as internal will set all overridden tasks to be internal as well: + +```yaml +version: '3' + +overrides: + utils: + taskfile: ./utils.yml + internal: true +``` + +### Variables in overrides + +You can specify variables when overriding, just like with includes: + +```yaml +version: '3' + +overrides: + customized: + taskfile: ./base.yml + vars: + ENVIRONMENT: "production" + VERSION: "2.1.0" +``` + +### Directory for overridden tasks + +By default, overridden tasks run in the current directory, but you can specify +a different directory: + +```yaml +version: '3' + +overrides: + backend: + taskfile: ./backend/tasks.yml + dir: ./backend +``` + +### Excluding tasks from overrides + +You can exclude specific tasks from being overridden: + +```yaml +version: '3' + +overrides: + lib: + taskfile: ./lib.yml + excludes: [internal_task, helper] +``` + +### Namespace aliases for overrides + +Even though overrides are automatically flattened, you can still use aliases +for organizational purposes: + +```yaml +version: '3' + +overrides: + library: + taskfile: ./library.yml + aliases: [lib] +``` + +### Combining overrides with includes + +Overrides work seamlessly with includes. Includes preserve namespaces while +overrides flatten and replace: + +```yaml +version: '3' + +includes: + utils: ./utils.yml # Available as utils:task-name + +overrides: + customizations: ./custom.yml # Available directly as task-name + +tasks: + main: + cmds: + - task: utils:helper # Included task with namespace + - task: custom_task # Overridden task without namespace +``` + +:::info + +Like includes, overridden Taskfiles must use the same schema version as the main +Taskfile. Variables declared in overridden Taskfiles take preference over +variables in the overriding Taskfile. + +::: + ## Internal tasks Internal tasks are tasks that cannot be called directly by the user. They will diff --git a/website/static/next-schema.json b/website/static/next-schema.json index 0a229bedc9..28b30c863d 100644 --- a/website/static/next-schema.json +++ b/website/static/next-schema.json @@ -695,6 +695,67 @@ } } }, + "overrides": { + "description": "Imports tasks from the specified taskfiles with override semantics. Tasks are automatically flattened and duplicate names replace existing tasks instead of causing errors.", + "type": "object", + "patternProperties": { + "^.*$": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "object", + "properties": { + "taskfile": { + "description": "The path for the Taskfile or directory to be overridden. If a directory, Task will look for files named `Taskfile.yml` or `Taskfile.yaml` inside that directory. If a relative path, resolved relative to the directory containing the overriding Taskfile.", + "type": "string" + }, + "dir": { + "description": "The working directory of the overridden tasks when run.", + "type": "string" + }, + "optional": { + "description": "If `true`, no errors will be thrown if the specified file does not exist.", + "type": "boolean" + }, + "flatten": { + "description": "Always `true` for overrides. Tasks from the overridden Taskfile are automatically flattened into the overriding Taskfile.", + "type": "boolean", + "default": true + }, + "internal": { + "description": "Stops any task in the overridden Taskfile from being callable on the command line. These commands will also be omitted from the output when used with `--list`.", + "type": "boolean" + }, + "aliases": { + "description": "Alternative names for the namespace of the overridden Taskfile (mainly for organizational purposes since overrides are flattened).", + "type": "array", + "items": { + "type": "string" + } + }, + "excludes": { + "description": "A list of task names to exclude from being overridden.", + "type": "array", + "items": { + "type": "string" + } + }, + "vars": { + "description": "A set of variables to apply to the overridden Taskfile.", + "$ref": "#/definitions/vars" + }, + "checksum": { + "description": "The checksum of the file you expect to override. If the checksum does not match, the file will not be overridden.", + "type": "string" + } + } + } + ] + } + } + }, "vars": { "description": "A set of global variables.", "$ref": "#/definitions/vars" @@ -748,11 +809,23 @@ { "required": ["includes"] }, + { + "required": ["overrides"] + }, { "required": ["tasks"] }, { "required": ["includes", "tasks"] + }, + { + "required": ["overrides", "tasks"] + }, + { + "required": ["includes", "overrides"] + }, + { + "required": ["includes", "overrides", "tasks"] } ] } diff --git a/website/static/schema.json b/website/static/schema.json index 0a229bedc9..28b30c863d 100644 --- a/website/static/schema.json +++ b/website/static/schema.json @@ -695,6 +695,67 @@ } } }, + "overrides": { + "description": "Imports tasks from the specified taskfiles with override semantics. Tasks are automatically flattened and duplicate names replace existing tasks instead of causing errors.", + "type": "object", + "patternProperties": { + "^.*$": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "object", + "properties": { + "taskfile": { + "description": "The path for the Taskfile or directory to be overridden. If a directory, Task will look for files named `Taskfile.yml` or `Taskfile.yaml` inside that directory. If a relative path, resolved relative to the directory containing the overriding Taskfile.", + "type": "string" + }, + "dir": { + "description": "The working directory of the overridden tasks when run.", + "type": "string" + }, + "optional": { + "description": "If `true`, no errors will be thrown if the specified file does not exist.", + "type": "boolean" + }, + "flatten": { + "description": "Always `true` for overrides. Tasks from the overridden Taskfile are automatically flattened into the overriding Taskfile.", + "type": "boolean", + "default": true + }, + "internal": { + "description": "Stops any task in the overridden Taskfile from being callable on the command line. These commands will also be omitted from the output when used with `--list`.", + "type": "boolean" + }, + "aliases": { + "description": "Alternative names for the namespace of the overridden Taskfile (mainly for organizational purposes since overrides are flattened).", + "type": "array", + "items": { + "type": "string" + } + }, + "excludes": { + "description": "A list of task names to exclude from being overridden.", + "type": "array", + "items": { + "type": "string" + } + }, + "vars": { + "description": "A set of variables to apply to the overridden Taskfile.", + "$ref": "#/definitions/vars" + }, + "checksum": { + "description": "The checksum of the file you expect to override. If the checksum does not match, the file will not be overridden.", + "type": "string" + } + } + } + ] + } + } + }, "vars": { "description": "A set of global variables.", "$ref": "#/definitions/vars" @@ -748,11 +809,23 @@ { "required": ["includes"] }, + { + "required": ["overrides"] + }, { "required": ["tasks"] }, { "required": ["includes", "tasks"] + }, + { + "required": ["overrides", "tasks"] + }, + { + "required": ["includes", "overrides"] + }, + { + "required": ["includes", "overrides", "tasks"] } ] } From b3f3b140c8aa46b46f12d0e6156a5a8feed118ab Mon Sep 17 00:00:00 2001 From: Lea Anthony Date: Sat, 9 Aug 2025 13:05:07 +1000 Subject: [PATCH 2/4] Linting fixes --- task_test.go | 2 +- taskfile/ast/override.go | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/task_test.go b/task_test.go index 16bbf5289b..79b4b0d544 100644 --- a/task_test.go +++ b/task_test.go @@ -2571,7 +2571,7 @@ func TestOverrides(t *testing.T) { t.Run("basic_override", func(t *testing.T) { t.Parallel() - + var buff bytes.Buffer e := task.NewExecutor( task.WithDir("testdata/overrides"), diff --git a/taskfile/ast/override.go b/taskfile/ast/override.go index aa0f2840e9..e6fc8f8321 100644 --- a/taskfile/ast/override.go +++ b/taskfile/ast/override.go @@ -209,4 +209,4 @@ func (override *Override) DeepCopy() *Override { Aliases: deepcopy.Slice(override.Aliases), Checksum: override.Checksum, } -} \ No newline at end of file +} From 02e447f95a2f0180787f37e945b1974b07d6027a Mon Sep 17 00:00:00 2001 From: Lea Anthony Date: Sat, 9 Aug 2025 16:13:04 +1000 Subject: [PATCH 3/4] Revert schema.json changes --- website/static/schema.json | 61 -------------------------------------- 1 file changed, 61 deletions(-) diff --git a/website/static/schema.json b/website/static/schema.json index 28b30c863d..812b5dfe6f 100644 --- a/website/static/schema.json +++ b/website/static/schema.json @@ -695,67 +695,6 @@ } } }, - "overrides": { - "description": "Imports tasks from the specified taskfiles with override semantics. Tasks are automatically flattened and duplicate names replace existing tasks instead of causing errors.", - "type": "object", - "patternProperties": { - "^.*$": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "object", - "properties": { - "taskfile": { - "description": "The path for the Taskfile or directory to be overridden. If a directory, Task will look for files named `Taskfile.yml` or `Taskfile.yaml` inside that directory. If a relative path, resolved relative to the directory containing the overriding Taskfile.", - "type": "string" - }, - "dir": { - "description": "The working directory of the overridden tasks when run.", - "type": "string" - }, - "optional": { - "description": "If `true`, no errors will be thrown if the specified file does not exist.", - "type": "boolean" - }, - "flatten": { - "description": "Always `true` for overrides. Tasks from the overridden Taskfile are automatically flattened into the overriding Taskfile.", - "type": "boolean", - "default": true - }, - "internal": { - "description": "Stops any task in the overridden Taskfile from being callable on the command line. These commands will also be omitted from the output when used with `--list`.", - "type": "boolean" - }, - "aliases": { - "description": "Alternative names for the namespace of the overridden Taskfile (mainly for organizational purposes since overrides are flattened).", - "type": "array", - "items": { - "type": "string" - } - }, - "excludes": { - "description": "A list of task names to exclude from being overridden.", - "type": "array", - "items": { - "type": "string" - } - }, - "vars": { - "description": "A set of variables to apply to the overridden Taskfile.", - "$ref": "#/definitions/vars" - }, - "checksum": { - "description": "The checksum of the file you expect to override. If the checksum does not match, the file will not be overridden.", - "type": "string" - } - } - } - ] - } - } - }, "vars": { "description": "A set of global variables.", "$ref": "#/definitions/vars" From 78ca7c9a3749fc229bf139361b90aa0a23f40ee7 Mon Sep 17 00:00:00 2001 From: Lea Anthony Date: Sat, 9 Aug 2025 16:16:51 +1000 Subject: [PATCH 4/4] Revert schema.json changes --- website/static/schema.json | 12 ------------ 1 file changed, 12 deletions(-) diff --git a/website/static/schema.json b/website/static/schema.json index 812b5dfe6f..0a229bedc9 100644 --- a/website/static/schema.json +++ b/website/static/schema.json @@ -748,23 +748,11 @@ { "required": ["includes"] }, - { - "required": ["overrides"] - }, { "required": ["tasks"] }, { "required": ["includes", "tasks"] - }, - { - "required": ["overrides", "tasks"] - }, - { - "required": ["includes", "overrides"] - }, - { - "required": ["includes", "overrides", "tasks"] } ] }