diff --git a/cmd/task/task.go b/cmd/task/task.go
index b81e23dd5f..9274b24726 100644
--- a/cmd/task/task.go
+++ b/cmd/task/task.go
@@ -174,6 +174,8 @@ func run() error {
// Merge CLI variables first (e.g. FOO=bar) so they take priority over Taskfile defaults
e.Taskfile.Vars.Merge(globals, nil)
+ // Store CLI vars for scoped mode where they need highest priority
+ e.Compiler.CLIVars = globals
// Then ReverseMerge special variables so they're available for templating
cliArgsPostDashQuoted, err := args.ToQuotedString(cliArgsPostDash)
diff --git a/compiler.go b/compiler.go
index 311fd58423..4689e41062 100644
--- a/compiler.go
+++ b/compiler.go
@@ -9,6 +9,7 @@ import (
"strings"
"sync"
+ "github.com/go-task/task/v3/experiments"
"github.com/go-task/task/v3/internal/env"
"github.com/go-task/task/v3/internal/execext"
"github.com/go-task/task/v3/internal/filepathext"
@@ -25,6 +26,8 @@ type Compiler struct {
TaskfileEnv *ast.Vars
TaskfileVars *ast.Vars
+ CLIVars *ast.Vars // CLI vars passed via command line (e.g., task foo VAR=value)
+ Graph *ast.TaskfileGraph
Logger *logger.Logger
@@ -44,8 +47,236 @@ func (c *Compiler) FastGetVariables(t *ast.Task, call *Call) (*ast.Vars, error)
return c.getVariables(t, call, false)
}
+// isScopedMode returns true if scoped variable resolution should be used.
+// Scoped mode requires the experiment to be enabled, a task with location info, and a graph.
+func (c *Compiler) isScopedMode(t *ast.Task) bool {
+ return experiments.ScopedTaskfiles.Enabled() &&
+ t != nil &&
+ t.Location != nil &&
+ c.Graph != nil
+}
+
func (c *Compiler) getVariables(t *ast.Task, call *Call, evaluateShVars bool) (*ast.Vars, error) {
+ if c.isScopedMode(t) {
+ return c.getScopedVariables(t, call, evaluateShVars)
+ }
+ return c.getLegacyVariables(t, call, evaluateShVars)
+}
+
+// getScopedVariables resolves variables in scoped mode.
+// In scoped mode:
+// - OS env vars are in {{.env.XXX}} namespace, not at root
+// - Variables from sibling includes are isolated
+//
+// Variable resolution order (lowest to highest priority):
+// 1. Root Taskfile vars
+// 2. Include Taskfile vars
+// 3. Include passthrough vars (includes: name: vars:)
+// 4. Task vars
+// 5. Call vars
+// 6. CLI vars
+func (c *Compiler) getScopedVariables(t *ast.Task, call *Call, evaluateShVars bool) (*ast.Vars, error) {
+ result := ast.NewVars()
+
+ specialVars, err := c.getSpecialVars(t, call)
+ if err != nil {
+ return nil, err
+ }
+ for k, v := range specialVars {
+ result.Set(k, ast.Var{Value: v})
+ }
+
+ getRangeFunc := func(dir string) func(k string, v ast.Var) error {
+ return func(k string, v ast.Var) error {
+ cache := &templater.Cache{Vars: result}
+ newVar := templater.ReplaceVar(v, cache)
+ if !evaluateShVars && newVar.Value == nil {
+ result.Set(k, ast.Var{Value: "", Sh: newVar.Sh})
+ return nil
+ }
+ if !evaluateShVars {
+ result.Set(k, ast.Var{Value: newVar.Value, Sh: newVar.Sh})
+ return nil
+ }
+ if err := cache.Err(); err != nil {
+ return err
+ }
+ if newVar.Value != nil || newVar.Sh == nil {
+ result.Set(k, ast.Var{Value: newVar.Value})
+ return nil
+ }
+ static, err := c.HandleDynamicVar(newVar, dir, env.GetFromVars(result))
+ if err != nil {
+ return err
+ }
+ result.Set(k, ast.Var{Value: static})
+ return nil
+ }
+ }
+ rangeFunc := getRangeFunc(c.Dir)
+
+ var taskRangeFunc func(k string, v ast.Var) error
+ if t != nil {
+ cache := &templater.Cache{Vars: result}
+ dir := templater.Replace(t.Dir, cache)
+ if err := cache.Err(); err != nil {
+ return nil, err
+ }
+ dir = filepathext.SmartJoin(c.Dir, dir)
+ taskRangeFunc = getRangeFunc(dir)
+ }
+
+ rootVertex, err := c.Graph.Root()
+ if err != nil {
+ return nil, err
+ }
+
+ envMap := make(map[string]any)
+ for _, e := range os.Environ() {
+ k, v, _ := strings.Cut(e, "=")
+ envMap[k] = v
+ }
+
+ resolveEnvToMap := func(k string, v ast.Var, dir string) error {
+ cache := &templater.Cache{Vars: result}
+ newVar := templater.ReplaceVar(v, cache)
+ if err := cache.Err(); err != nil {
+ return err
+ }
+ if newVar.Value != nil || newVar.Sh == nil {
+ if newVar.Value != nil {
+ envMap[k] = newVar.Value
+ }
+ return nil
+ }
+ if evaluateShVars {
+ envSlice := os.Environ()
+ for ek, ev := range envMap {
+ if s, ok := ev.(string); ok {
+ envSlice = append(envSlice, fmt.Sprintf("%s=%s", ek, s))
+ }
+ }
+ static, err := c.HandleDynamicVar(newVar, dir, envSlice)
+ if err != nil {
+ return err
+ }
+ envMap[k] = static
+ }
+ return nil
+ }
+
+ for k, v := range rootVertex.Taskfile.Env.All() {
+ if err := resolveEnvToMap(k, v, c.Dir); err != nil {
+ return nil, err
+ }
+ }
+
+ for k, v := range rootVertex.Taskfile.Vars.All() {
+ if err := rangeFunc(k, v); err != nil {
+ return nil, err
+ }
+ }
+
+ if t.Location.Taskfile != rootVertex.URI {
+ predecessorMap, err := c.Graph.PredecessorMap()
+ if err != nil {
+ return nil, err
+ }
+
+ var parentChain []*ast.TaskfileVertex
+ currentURI := t.Location.Taskfile
+ for {
+ edges := predecessorMap[currentURI]
+ if len(edges) == 0 {
+ break
+ }
+ var parentURI string
+ for _, edge := range edges {
+ parentURI = edge.Source
+ break
+ }
+ if parentURI == rootVertex.URI {
+ break
+ }
+ parentVertex, err := c.Graph.Vertex(parentURI)
+ if err != nil {
+ return nil, err
+ }
+ parentChain = append([]*ast.TaskfileVertex{parentVertex}, parentChain...)
+ currentURI = parentURI
+ }
+
+ for _, parent := range parentChain {
+ parentDir := filepath.Dir(parent.URI)
+ for k, v := range parent.Taskfile.Env.All() {
+ if err := resolveEnvToMap(k, v, parentDir); err != nil {
+ return nil, err
+ }
+ }
+ // Vars use the parent's directory too
+ parentRangeFunc := getRangeFunc(parentDir)
+ for k, v := range parent.Taskfile.Vars.All() {
+ if err := parentRangeFunc(k, v); err != nil {
+ return nil, err
+ }
+ }
+ }
+
+ includeVertex, err := c.Graph.Vertex(t.Location.Taskfile)
+ if err != nil {
+ return nil, err
+ }
+ includeDir := filepath.Dir(includeVertex.URI)
+ for k, v := range includeVertex.Taskfile.Env.All() {
+ if err := resolveEnvToMap(k, v, includeDir); err != nil {
+ return nil, err
+ }
+ }
+ includeRangeFunc := getRangeFunc(includeDir)
+ for k, v := range includeVertex.Taskfile.Vars.All() {
+ if err := includeRangeFunc(k, v); err != nil {
+ return nil, err
+ }
+ }
+ }
+
+ if t.IncludeVars != nil {
+ for k, v := range t.IncludeVars.All() {
+ if err := rangeFunc(k, v); err != nil {
+ return nil, err
+ }
+ }
+ }
+
+ if call != nil {
+ for k, v := range t.Vars.All() {
+ if err := taskRangeFunc(k, v); err != nil {
+ return nil, err
+ }
+ }
+ for k, v := range call.Vars.All() {
+ if err := taskRangeFunc(k, v); err != nil {
+ return nil, err
+ }
+ }
+ }
+
+ for k, v := range c.CLIVars.All() {
+ if err := rangeFunc(k, v); err != nil {
+ return nil, err
+ }
+ }
+
+ result.Set("env", ast.Var{Value: envMap})
+
+ return result, nil
+}
+
+// getLegacyVariables resolves variables in legacy mode.
+// In legacy mode, all variables (including OS env) are merged at root level.
+func (c *Compiler) getLegacyVariables(t *ast.Task, call *Call, evaluateShVars bool) (*ast.Vars, error) {
result := env.GetEnviron()
+
specialVars, err := c.getSpecialVars(t, call)
if err != nil {
return nil, err
@@ -57,30 +288,22 @@ func (c *Compiler) getVariables(t *ast.Task, call *Call, evaluateShVars bool) (*
getRangeFunc := func(dir string) func(k string, v ast.Var) error {
return func(k string, v ast.Var) error {
cache := &templater.Cache{Vars: result}
- // Replace values
newVar := templater.ReplaceVar(v, cache)
- // If the variable should not be evaluated, but is nil, set it to an empty string
- // This stops empty interface errors when using the templater to replace values later
- // Preserve the Sh field so it can be displayed in summary
if !evaluateShVars && newVar.Value == nil {
result.Set(k, ast.Var{Value: "", Sh: newVar.Sh})
return nil
}
- // If the variable should not be evaluated and it is set, we can set it and return
if !evaluateShVars {
result.Set(k, ast.Var{Value: newVar.Value, Sh: newVar.Sh})
return nil
}
- // Now we can check for errors since we've handled all the cases when we don't want to evaluate
if err := cache.Err(); err != nil {
return err
}
- // If the variable is already set, we can set it and return
if newVar.Value != nil || newVar.Sh == nil {
result.Set(k, ast.Var{Value: newVar.Value})
return nil
}
- // If the variable is dynamic, we need to resolve it first
static, err := c.HandleDynamicVar(newVar, dir, env.GetFromVars(result))
if err != nil {
return err
@@ -93,8 +316,6 @@ func (c *Compiler) getVariables(t *ast.Task, call *Call, evaluateShVars bool) (*
var taskRangeFunc func(k string, v ast.Var) error
if t != nil {
- // NOTE(@andreynering): We're manually joining these paths here because
- // this is the raw task, not the compiled one.
cache := &templater.Cache{Vars: result}
dir := templater.Replace(t.Dir, cache)
if err := cache.Err(); err != nil {
@@ -114,6 +335,7 @@ func (c *Compiler) getVariables(t *ast.Task, call *Call, evaluateShVars bool) (*
return nil, err
}
}
+
if t != nil {
for k, v := range t.IncludeVars.All() {
if err := rangeFunc(k, v); err != nil {
@@ -149,7 +371,6 @@ func (c *Compiler) HandleDynamicVar(v ast.Var, dir string, e []string) (string,
c.muDynamicCache.Lock()
defer c.muDynamicCache.Unlock()
- // If the variable is not dynamic or it is empty, return an empty string
if v.Sh == nil || *v.Sh == "" {
return "", nil
}
diff --git a/executor.go b/executor.go
index 43b8bacffe..a6632a30ef 100644
--- a/executor.go
+++ b/executor.go
@@ -63,6 +63,7 @@ type (
// Internal
Taskfile *ast.Taskfile
+ Graph *ast.TaskfileGraph
Logger *logger.Logger
Compiler *Compiler
Output output.Output
diff --git a/executor_test.go b/executor_test.go
index d810e652f0..ef4ab033bd 100644
--- a/executor_test.go
+++ b/executor_test.go
@@ -1180,3 +1180,114 @@ func TestIf(t *testing.T) {
NewExecutorTest(t, opts...)
}
}
+
+//nolint:paralleltest // enableExperimentForTest modifies global state
+func TestScopedTaskfiles(t *testing.T) {
+ // Legacy tests (without experiment) - vars should be merged globally
+ t.Run("legacy", func(t *testing.T) {
+ // Test with scoped taskfiles disabled (legacy) - vars should be merged globally
+ NewExecutorTest(t,
+ WithName("default"),
+ WithExecutorOptions(
+ task.WithDir("testdata/scoped_taskfiles"),
+ task.WithSilent(true),
+ ),
+ )
+ // In legacy mode, UNIQUE_B should be accessible (merged globally)
+ NewExecutorTest(t,
+ WithName("cross-include"),
+ WithExecutorOptions(
+ task.WithDir("testdata/scoped_taskfiles"),
+ task.WithSilent(true),
+ ),
+ WithTask("a:try-access-b"),
+ )
+ })
+
+ // Scoped tests (with experiment enabled) - vars should be isolated
+ t.Run("scoped", func(t *testing.T) {
+ enableExperimentForTest(t, &experiments.ScopedTaskfiles, 1)
+
+ // Test with scoped taskfiles enabled - vars should be isolated
+ NewExecutorTest(t,
+ WithName("default"),
+ WithExecutorOptions(
+ task.WithDir("testdata/scoped_taskfiles"),
+ task.WithSilent(true),
+ ),
+ )
+ // Test inheritance: include can access root vars
+ NewExecutorTest(t,
+ WithName("inheritance-a"),
+ WithExecutorOptions(
+ task.WithDir("testdata/scoped_taskfiles"),
+ task.WithSilent(true),
+ ),
+ WithTask("a:print"),
+ )
+ // Test isolation: each include sees its own vars
+ NewExecutorTest(t,
+ WithName("isolation-b"),
+ WithExecutorOptions(
+ task.WithDir("testdata/scoped_taskfiles"),
+ task.WithSilent(true),
+ ),
+ WithTask("b:print"),
+ )
+ // In scoped mode, UNIQUE_B should be empty (isolated)
+ NewExecutorTest(t,
+ WithName("cross-include"),
+ WithExecutorOptions(
+ task.WithDir("testdata/scoped_taskfiles"),
+ task.WithSilent(true),
+ ),
+ WithTask("a:try-access-b"),
+ )
+ // Test env namespace: {{.env.XXX}} should access env vars
+ NewExecutorTest(t,
+ WithName("env-namespace"),
+ WithExecutorOptions(
+ task.WithDir("testdata/scoped_taskfiles"),
+ task.WithSilent(true),
+ ),
+ WithTask("print-env"),
+ )
+ // Test env separation: {{.ROOT_ENV}} at root should be empty (env not at root level)
+ NewExecutorTest(t,
+ WithName("env-separation"),
+ WithExecutorOptions(
+ task.WithDir("testdata/scoped_taskfiles"),
+ task.WithSilent(true),
+ ),
+ WithTask("test-env-separation"),
+ )
+ // Test include env: include's env is accessible via {{.env.XXX}}
+ NewExecutorTest(t,
+ WithName("include-env"),
+ WithExecutorOptions(
+ task.WithDir("testdata/scoped_taskfiles"),
+ task.WithSilent(true),
+ ),
+ WithTask("a:print-env"),
+ )
+ // Test call vars: vars passed when calling a task override task vars
+ NewExecutorTest(t,
+ WithName("call-vars"),
+ WithExecutorOptions(
+ task.WithDir("testdata/scoped_taskfiles"),
+ task.WithSilent(true),
+ ),
+ WithTask("call-with-vars"),
+ )
+ // Test nested includes (3 levels: root → a → nested)
+ // Verifies that nested includes inherit vars from their parent chain
+ NewExecutorTest(t,
+ WithName("nested"),
+ WithExecutorOptions(
+ task.WithDir("testdata/scoped_taskfiles"),
+ task.WithSilent(true),
+ ),
+ WithTask("a:nested:print"),
+ )
+ })
+}
diff --git a/experiments/experiments.go b/experiments/experiments.go
index 3e14d39a03..7b4e0a4c09 100644
--- a/experiments/experiments.go
+++ b/experiments/experiments.go
@@ -19,6 +19,7 @@ var (
GentleForce Experiment
RemoteTaskfiles Experiment
EnvPrecedence Experiment
+ ScopedTaskfiles Experiment
)
// Inactive experiments. These are experiments that cannot be enabled, but are
@@ -43,6 +44,7 @@ func ParseWithConfig(dir string, config *ast.TaskRC) {
GentleForce = New("GENTLE_FORCE", config, 1)
RemoteTaskfiles = New("REMOTE_TASKFILES", config, 1)
EnvPrecedence = New("ENV_PRECEDENCE", config, 1)
+ ScopedTaskfiles = New("SCOPED_TASKFILES", config, 1)
AnyVariables = New("ANY_VARIABLES", config)
MapVariables = New("MAP_VARIABLES", config)
}
diff --git a/setup.go b/setup.go
index fbbe64e746..de56cbe46a 100644
--- a/setup.go
+++ b/setup.go
@@ -13,6 +13,7 @@ import (
"github.com/sajari/fuzzy"
"github.com/go-task/task/v3/errors"
+ "github.com/go-task/task/v3/experiments"
"github.com/go-task/task/v3/internal/env"
"github.com/go-task/task/v3/internal/execext"
"github.com/go-task/task/v3/internal/filepathext"
@@ -104,7 +105,8 @@ func (e *Executor) readTaskfile(node taskfile.Node) error {
}
return err
}
- if e.Taskfile, err = graph.Merge(); err != nil {
+ e.Graph = graph
+ if e.Taskfile, err = graph.Merge(experiments.ScopedTaskfiles.Enabled()); err != nil {
return err
}
return nil
@@ -226,6 +228,7 @@ func (e *Executor) setupCompiler() error {
UserWorkingDir: e.UserWorkingDir,
TaskfileEnv: e.Taskfile.Env,
TaskfileVars: e.Taskfile.Vars,
+ Graph: e.Graph,
Logger: e.Logger,
}
return nil
diff --git a/taskfile/ast/graph.go b/taskfile/ast/graph.go
index cb30093d88..ab961b7d84 100644
--- a/taskfile/ast/graph.go
+++ b/taskfile/ast/graph.go
@@ -45,7 +45,21 @@ func (tfg *TaskfileGraph) Visualize(filename string) error {
return draw.DOT(tfg.Graph, f)
}
-func (tfg *TaskfileGraph) Merge() (*Taskfile, error) {
+// Root returns the root vertex of the graph (the entrypoint Taskfile).
+func (tfg *TaskfileGraph) Root() (*TaskfileVertex, error) {
+ hashes, err := graph.TopologicalSort(tfg.Graph)
+ if err != nil {
+ return nil, err
+ }
+ if len(hashes) == 0 {
+ return nil, fmt.Errorf("task: graph has no vertices")
+ }
+ return tfg.Vertex(hashes[0])
+}
+
+// Merge merges all included Taskfiles into the root Taskfile.
+// If skipVarsMerge is true, variables are not merged (used for scoped includes).
+func (tfg *TaskfileGraph) Merge(skipVarsMerge bool) (*Taskfile, error) {
hashes, err := graph.TopologicalSort(tfg.Graph)
if err != nil {
return nil, err
@@ -92,6 +106,7 @@ func (tfg *TaskfileGraph) Merge() (*Taskfile, error) {
if err := vertex.Taskfile.Merge(
includedVertex.Taskfile,
include,
+ skipVarsMerge,
); err != nil {
return err
}
diff --git a/taskfile/ast/taskfile.go b/taskfile/ast/taskfile.go
index 4ae1dbac84..a56d2262dc 100644
--- a/taskfile/ast/taskfile.go
+++ b/taskfile/ast/taskfile.go
@@ -36,8 +36,9 @@ type Taskfile struct {
Interval time.Duration
}
-// Merge merges the second Taskfile into the first
-func (t1 *Taskfile) Merge(t2 *Taskfile, include *Include) error {
+// Merge merges the second Taskfile into the first.
+// If skipVarsMerge is true, variables are not merged (used for scoped includes).
+func (t1 *Taskfile) Merge(t2 *Taskfile, include *Include, skipVarsMerge bool) 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)
}
@@ -67,8 +68,11 @@ func (t1 *Taskfile) Merge(t2 *Taskfile, include *Include) error {
}
}
}
- t1.Vars.Merge(t2.Vars, include)
- t1.Env.Merge(t2.Env, include)
+ // Only merge vars if not using scoped includes, or if flattening
+ if !skipVarsMerge || include.Flatten {
+ t1.Vars.Merge(t2.Vars, include)
+ t1.Env.Merge(t2.Env, include)
+ }
return t1.Tasks.Merge(t2.Tasks, include, t1.Vars)
}
diff --git a/testdata/scoped_taskfiles/Taskfile.yml b/testdata/scoped_taskfiles/Taskfile.yml
new file mode 100644
index 0000000000..8cff71139e
--- /dev/null
+++ b/testdata/scoped_taskfiles/Taskfile.yml
@@ -0,0 +1,57 @@
+version: "3"
+
+env:
+ ROOT_ENV: env_from_root
+ SHARED_ENV: shared_from_root
+
+vars:
+ ROOT_VAR: from_root
+
+includes:
+ a: ./inc_a
+ b: ./inc_b
+
+tasks:
+ default:
+ desc: Test scoped includes - vars should be isolated
+ cmds:
+ - task: a:print
+ - task: b:print
+
+ print-root-var:
+ desc: Print ROOT_VAR from root
+ cmds:
+ - echo "ROOT_VAR={{.ROOT_VAR}}"
+
+ print-env:
+ desc: Print env vars using {{.env.XXX}} syntax
+ cmds:
+ - echo "ROOT_ENV={{.env.ROOT_ENV}}"
+ - echo "SHARED_ENV={{.env.SHARED_ENV}}"
+ - echo "PATH_EXISTS={{if .env.PATH}}yes{{else}}no{{end}}"
+
+ test-env-separation:
+ desc: Test that env is NOT at root level in scoped mode
+ cmds:
+ # In scoped mode, {{.ROOT_ENV}} should be empty (env not at root)
+ # In legacy mode, {{.ROOT_ENV}} would have the value
+ - echo "ROOT_ENV_AT_ROOT={{.ROOT_ENV}}"
+
+ prout:
+ vars:
+ LOL: prout_from_root
+ cmds:
+ - echo "{{.LOL}}"
+
+ call-with-vars:
+ desc: Test calling a task with vars override
+ cmds:
+ - task: print-name
+ vars:
+ NAME: from_caller
+
+ print-name:
+ vars:
+ NAME: default_name
+ cmds:
+ - echo "NAME={{.NAME}}"
diff --git a/testdata/scoped_taskfiles/inc_a/Taskfile.yml b/testdata/scoped_taskfiles/inc_a/Taskfile.yml
new file mode 100644
index 0000000000..ef652c6f88
--- /dev/null
+++ b/testdata/scoped_taskfiles/inc_a/Taskfile.yml
@@ -0,0 +1,38 @@
+version: "3"
+
+env:
+ INC_A_ENV: env_from_a
+ SHARED_ENV: shared_from_a
+
+vars:
+ VAR: value_from_a
+ UNIQUE_A: only_in_a
+
+includes:
+ nested: ./nested
+
+tasks:
+ print:
+ desc: Print vars from include A
+ cmds:
+ - echo "A:UNIQUE_A={{.UNIQUE_A}}"
+ - echo "A:ROOT_VAR={{.ROOT_VAR}}"
+
+ try-access-b:
+ desc: Try to access B's unique var (should fail in scoped mode)
+ cmds:
+ - echo "A:UNIQUE_B={{.UNIQUE_B}}"
+
+ print-env:
+ desc: Print env vars from include A
+ cmds:
+ - echo "A:INC_A_ENV={{.env.INC_A_ENV}}"
+ - echo "A:ROOT_ENV={{.env.ROOT_ENV}}"
+ - echo "A:SHARED_ENV={{.env.SHARED_ENV}}"
+
+ test-env-in-var:
+ desc: Test using env in a var template
+ vars:
+ COMPOSED: "env={{.env.ROOT_ENV}}"
+ cmds:
+ - echo "{{.COMPOSED}}"
diff --git a/testdata/scoped_taskfiles/inc_a/nested/Taskfile.yml b/testdata/scoped_taskfiles/inc_a/nested/Taskfile.yml
new file mode 100644
index 0000000000..ef85e21703
--- /dev/null
+++ b/testdata/scoped_taskfiles/inc_a/nested/Taskfile.yml
@@ -0,0 +1,22 @@
+version: "3"
+
+env:
+ NESTED_ENV: env_from_nested
+
+vars:
+ NESTED_VAR: from_nested
+
+tasks:
+ print:
+ desc: Print vars from nested include (3 levels deep)
+ cmds:
+ - echo "NESTED:ROOT_VAR={{.ROOT_VAR}}"
+ - echo "NESTED:UNIQUE_A={{.UNIQUE_A}}"
+ - echo "NESTED:NESTED_VAR={{.NESTED_VAR}}"
+ - echo "NESTED:NESTED_ENV={{.env.NESTED_ENV}}"
+ - echo "NESTED:ROOT_ENV={{.env.ROOT_ENV}}"
+
+ try-access-b:
+ desc: Try to access B's unique var (should fail - sibling isolation)
+ cmds:
+ - echo "NESTED:UNIQUE_B={{.UNIQUE_B}}"
diff --git a/testdata/scoped_taskfiles/inc_b/Taskfile.yml b/testdata/scoped_taskfiles/inc_b/Taskfile.yml
new file mode 100644
index 0000000000..59c9c47cba
--- /dev/null
+++ b/testdata/scoped_taskfiles/inc_b/Taskfile.yml
@@ -0,0 +1,23 @@
+version: "3"
+
+env:
+ INC_B_ENV: env_from_b
+ SHARED_ENV: shared_from_b
+
+vars:
+ VAR: value_from_b
+ UNIQUE_B: only_in_b
+
+tasks:
+ print:
+ desc: Print vars from include B
+ cmds:
+ - echo "B:UNIQUE_B={{.UNIQUE_B}}"
+ - echo "B:ROOT_VAR={{.ROOT_VAR}}"
+
+ print-env:
+ desc: Print env vars from include B
+ cmds:
+ - echo "B:INC_B_ENV={{.env.INC_B_ENV}}"
+ - echo "B:ROOT_ENV={{.env.ROOT_ENV}}"
+ - echo "B:SHARED_ENV={{.env.SHARED_ENV}}"
diff --git a/testdata/scoped_taskfiles/testdata/TestScopedTaskfiles-legacy-cross-include.golden b/testdata/scoped_taskfiles/testdata/TestScopedTaskfiles-legacy-cross-include.golden
new file mode 100644
index 0000000000..5b2b94fc33
--- /dev/null
+++ b/testdata/scoped_taskfiles/testdata/TestScopedTaskfiles-legacy-cross-include.golden
@@ -0,0 +1 @@
+A:UNIQUE_B=only_in_b
diff --git a/testdata/scoped_taskfiles/testdata/TestScopedTaskfiles-legacy-default.golden b/testdata/scoped_taskfiles/testdata/TestScopedTaskfiles-legacy-default.golden
new file mode 100644
index 0000000000..5989213412
--- /dev/null
+++ b/testdata/scoped_taskfiles/testdata/TestScopedTaskfiles-legacy-default.golden
@@ -0,0 +1,4 @@
+A:UNIQUE_A=only_in_a
+A:ROOT_VAR=from_root
+B:UNIQUE_B=only_in_b
+B:ROOT_VAR=from_root
diff --git a/testdata/scoped_taskfiles/testdata/TestScopedTaskfiles-scoped-call-vars.golden b/testdata/scoped_taskfiles/testdata/TestScopedTaskfiles-scoped-call-vars.golden
new file mode 100644
index 0000000000..8a11e1d5cd
--- /dev/null
+++ b/testdata/scoped_taskfiles/testdata/TestScopedTaskfiles-scoped-call-vars.golden
@@ -0,0 +1 @@
+NAME=from_caller
diff --git a/testdata/scoped_taskfiles/testdata/TestScopedTaskfiles-scoped-cross-include.golden b/testdata/scoped_taskfiles/testdata/TestScopedTaskfiles-scoped-cross-include.golden
new file mode 100644
index 0000000000..0dcb063b80
--- /dev/null
+++ b/testdata/scoped_taskfiles/testdata/TestScopedTaskfiles-scoped-cross-include.golden
@@ -0,0 +1 @@
+A:UNIQUE_B=
diff --git a/testdata/scoped_taskfiles/testdata/TestScopedTaskfiles-scoped-default.golden b/testdata/scoped_taskfiles/testdata/TestScopedTaskfiles-scoped-default.golden
new file mode 100644
index 0000000000..5989213412
--- /dev/null
+++ b/testdata/scoped_taskfiles/testdata/TestScopedTaskfiles-scoped-default.golden
@@ -0,0 +1,4 @@
+A:UNIQUE_A=only_in_a
+A:ROOT_VAR=from_root
+B:UNIQUE_B=only_in_b
+B:ROOT_VAR=from_root
diff --git a/testdata/scoped_taskfiles/testdata/TestScopedTaskfiles-scoped-env-namespace.golden b/testdata/scoped_taskfiles/testdata/TestScopedTaskfiles-scoped-env-namespace.golden
new file mode 100644
index 0000000000..6f3d53916f
--- /dev/null
+++ b/testdata/scoped_taskfiles/testdata/TestScopedTaskfiles-scoped-env-namespace.golden
@@ -0,0 +1,3 @@
+ROOT_ENV=env_from_root
+SHARED_ENV=shared_from_root
+PATH_EXISTS=yes
diff --git a/testdata/scoped_taskfiles/testdata/TestScopedTaskfiles-scoped-env-separation.golden b/testdata/scoped_taskfiles/testdata/TestScopedTaskfiles-scoped-env-separation.golden
new file mode 100644
index 0000000000..9a4d6ae270
--- /dev/null
+++ b/testdata/scoped_taskfiles/testdata/TestScopedTaskfiles-scoped-env-separation.golden
@@ -0,0 +1 @@
+ROOT_ENV_AT_ROOT=
diff --git a/testdata/scoped_taskfiles/testdata/TestScopedTaskfiles-scoped-include-env.golden b/testdata/scoped_taskfiles/testdata/TestScopedTaskfiles-scoped-include-env.golden
new file mode 100644
index 0000000000..28650ffb6c
--- /dev/null
+++ b/testdata/scoped_taskfiles/testdata/TestScopedTaskfiles-scoped-include-env.golden
@@ -0,0 +1,3 @@
+A:INC_A_ENV=env_from_a
+A:ROOT_ENV=env_from_root
+A:SHARED_ENV=shared_from_a
diff --git a/testdata/scoped_taskfiles/testdata/TestScopedTaskfiles-scoped-inheritance-a.golden b/testdata/scoped_taskfiles/testdata/TestScopedTaskfiles-scoped-inheritance-a.golden
new file mode 100644
index 0000000000..c5f9d73547
--- /dev/null
+++ b/testdata/scoped_taskfiles/testdata/TestScopedTaskfiles-scoped-inheritance-a.golden
@@ -0,0 +1,2 @@
+A:UNIQUE_A=only_in_a
+A:ROOT_VAR=from_root
diff --git a/testdata/scoped_taskfiles/testdata/TestScopedTaskfiles-scoped-isolation-b.golden b/testdata/scoped_taskfiles/testdata/TestScopedTaskfiles-scoped-isolation-b.golden
new file mode 100644
index 0000000000..eb8af5e3af
--- /dev/null
+++ b/testdata/scoped_taskfiles/testdata/TestScopedTaskfiles-scoped-isolation-b.golden
@@ -0,0 +1,2 @@
+B:UNIQUE_B=only_in_b
+B:ROOT_VAR=from_root
diff --git a/testdata/scoped_taskfiles/testdata/TestScopedTaskfiles-scoped-nested.golden b/testdata/scoped_taskfiles/testdata/TestScopedTaskfiles-scoped-nested.golden
new file mode 100644
index 0000000000..c43bb088b6
--- /dev/null
+++ b/testdata/scoped_taskfiles/testdata/TestScopedTaskfiles-scoped-nested.golden
@@ -0,0 +1,5 @@
+NESTED:ROOT_VAR=from_root
+NESTED:UNIQUE_A=only_in_a
+NESTED:NESTED_VAR=from_nested
+NESTED:NESTED_ENV=env_from_nested
+NESTED:ROOT_ENV=env_from_root
diff --git a/website/.vitepress/config.ts b/website/.vitepress/config.ts
index 24c675f16b..db44f510b1 100644
--- a/website/.vitepress/config.ts
+++ b/website/.vitepress/config.ts
@@ -302,6 +302,10 @@ export default defineConfig({
{
text: 'Remote Taskfiles (#1317)',
link: '/docs/experiments/remote-taskfiles'
+ },
+ {
+ text: 'Scoped Taskfiles',
+ link: '/docs/experiments/scoped-taskfiles'
}
]
},
diff --git a/website/src/docs/experiments/scoped-taskfiles.md b/website/src/docs/experiments/scoped-taskfiles.md
new file mode 100644
index 0000000000..d2d35b7785
--- /dev/null
+++ b/website/src/docs/experiments/scoped-taskfiles.md
@@ -0,0 +1,281 @@
+---
+title: 'Scoped Taskfiles (#2035)'
+description:
+ Experiment for variable isolation and env namespace in included Taskfiles
+outline: deep
+---
+
+# Scoped Taskfiles (#2035)
+
+::: warning
+
+All experimental features are subject to breaking changes and/or removal _at any
+time_. We strongly recommend that you do not use these features in a production
+environment. They are intended for testing and feedback only.
+
+:::
+
+::: danger
+
+This experiment breaks the following functionality:
+
+- **Environment variables are no longer available at root level in templates**
+ - Before: `{{.PATH}}`, `{{.MY_ENV}}`
+ - After: `{{.env.PATH}}`,
+ `{{.env.MY_ENV}}`
+- **Variables from sibling includes are no longer visible**
+ - Include A cannot access variables defined in Include B
+ - Each include only sees: root vars + its own vars + parent vars
+
+:::
+
+::: info
+
+To enable this experiment, set the environment variable:
+`TASK_X_SCOPED_TASKFILES=1`. Check out
+[our guide to enabling experiments](./index.md#enabling-experiments) for more
+information.
+
+:::
+
+This experiment introduces two major changes to how variables work in Task:
+
+1. **Environment namespace**: Environment variables (both OS and Taskfile `env:`
+ sections) are moved to a dedicated `{{.env.XXX}}`
+ namespace, separating them from regular variables
+2. **Variable scoping**: Variables defined in included Taskfiles are isolated -
+ sibling includes cannot see each other's variables
+
+## Environment Namespace
+
+With this experiment enabled, environment variables are no longer mixed with
+regular variables at the template root level. Instead, they are accessible
+through the `{{.env.XXX}}` namespace.
+
+### Comparison Table
+
+| Template | Legacy | SCOPED_TASKFILES |
+| ----------------------------------------------- | ------ | ------------------------- |
+| `{{.MY_VAR}}` (from `vars:`) | Works | Works |
+| `{{.MY_ENV}}` (from `env:`) | Works | `` |
+| `{{.env.MY_ENV}}` | - | Works |
+| `{{.PATH}}` (OS) | Works | `` |
+| `{{.env.PATH}}` (OS) | - | Works |
+| `{{.TASK}}` (special) | Works | Works (stays at root) |
+
+### Example
+
+```yaml
+version: '3'
+
+env:
+ DB_HOST: localhost
+
+vars:
+ DB_NAME: mydb
+
+tasks:
+ show:
+ cmds:
+ # Access Taskfile env: section
+ - echo "Host: {{.env.DB_HOST}}"
+
+ # Access regular vars (unchanged)
+ - echo "Name: {{.DB_NAME}}"
+
+ # Access OS environment variables
+ - echo "Path: {{.env.PATH}}"
+
+ # Special variables stay at root level
+ - echo "Task: {{.TASK}}"
+```
+
+## Variable Scoping
+
+Variables defined in included Taskfiles are now isolated from each other.
+Sibling includes cannot access each other's variables, but child includes can
+still inherit variables from their parent.
+
+### Example
+
+::: code-group
+
+```yaml [Taskfile.yml]
+version: '3'
+
+vars:
+ ROOT_VAR: from_root
+
+includes:
+ api: ./api
+ web: ./web
+```
+
+```yaml [api/Taskfile.yml]
+version: '3'
+
+vars:
+ API_VAR: from_api
+
+tasks:
+ show:
+ cmds:
+ # Inherited from root - works
+ - echo "ROOT_VAR={{.ROOT_VAR}}"
+
+ # Own variable - works
+ - echo "API_VAR={{.API_VAR}}"
+
+ # From sibling include - NOT visible
+ - echo "WEB_VAR={{.WEB_VAR}}"
+```
+
+```yaml [web/Taskfile.yml]
+version: '3'
+
+vars:
+ WEB_VAR: from_web
+
+tasks:
+ show:
+ cmds:
+ # Inherited from root - works
+ - echo "ROOT_VAR={{.ROOT_VAR}}"
+
+ # Own variable - works
+ - echo "WEB_VAR={{.WEB_VAR}}"
+
+ # From sibling include - NOT visible
+ - echo "API_VAR={{.API_VAR}}"
+```
+
+:::
+
+## Variable Priority
+
+With this experiment, variables follow a clear priority order (lowest to
+highest):
+
+| Priority | Source | Description |
+| -------- | ------------------------ | ---------------------------------------- |
+| 1 | Root Taskfile vars | `vars:` in the root Taskfile |
+| 2 | Include Taskfile vars | `vars:` in the included Taskfile |
+| 3 | Include passthrough vars | `includes: name: vars:` from parent |
+| 4 | Task vars | `tasks: name: vars:` in the task |
+| 5 | Call vars | `task: name` with `vars:` when calling |
+| 6 | CLI vars | `task foo VAR=value` on command line |
+
+### Example: Call vars override task vars
+
+```yaml
+version: '3'
+
+tasks:
+ greet:
+ vars:
+ NAME: default
+ cmds:
+ - echo "Hello {{.NAME}}"
+
+ caller:
+ cmds:
+ - task: greet
+ vars:
+ NAME: from_caller
+```
+
+```bash
+# Direct call uses task default
+task greet
+# Output: Hello default
+
+# Call vars override task vars
+task caller
+# Output: Hello from_caller
+
+# CLI vars override everything
+task greet NAME=cli
+# Output: Hello cli
+```
+
+## Migration Guide
+
+To migrate your Taskfiles to use this experiment:
+
+1. **Update environment variable references** in your templates:
+
+ - `{{.PATH}}` becomes
+ `{{.env.PATH}}`
+ - `{{.HOME}}` becomes
+ `{{.env.HOME}}`
+ - `{{.MY_TASKFILE_ENV}}` becomes
+ `{{.env.MY_TASKFILE_ENV}}`
+
+2. **Variables in `vars:` sections remain unchanged**:
+
+ - `{{.MY_VAR}}` still works the same way
+
+3. **Special variables stay at root level**:
+
+ - `{{.TASK}}`, `{{.ROOT_DIR}}`,
+ `{{.TASKFILE}}`, `{{.TASKFILE_DIR}}`,
+ etc.
+
+4. **Review cross-include variable dependencies**:
+ - If your included Taskfiles rely on variables from sibling includes, you'll
+ need to either move those variables to the root Taskfile or pass them
+ explicitly via the `vars:` attribute in the `includes:` section.
+
+5. **Use `flatten: true` for gradual migration**:
+ - If an include needs the legacy behavior (access to sibling variables), you
+ can use `flatten: true` on that include as an escape hatch.
+
+## Using `flatten: true`
+
+The `flatten: true` option on includes bypasses scoping for that specific
+include. When an include has `flatten: true`:
+
+- Its variables are merged globally (legacy behavior)
+- It can access variables from sibling includes
+- Sibling includes can access its variables
+
+This is useful for gradual migration or when you have includes that genuinely
+need to share variables.
+
+### Example
+
+```yaml
+version: '3'
+
+vars:
+ ROOT_VAR: from_root
+
+includes:
+ # Scoped include - isolated from siblings
+ api:
+ taskfile: ./api
+
+ # Flattened include - uses legacy merge behavior
+ shared:
+ taskfile: ./shared
+ flatten: true
+
+ # Another scoped include
+ web:
+ taskfile: ./web
+```
+
+In this example:
+
+- `api` and `web` are isolated from each other (cannot see each other's vars)
+- `shared` uses legacy behavior: its vars are merged globally
+- Both `api` and `web` can access variables from `shared`
+- `shared` can access variables from `api` and `web`
+
+::: tip
+
+Use `flatten: true` sparingly. The goal of scoped taskfiles is to improve
+isolation and predictability. Flattening should be a temporary measure during
+migration or for utility includes that genuinely need global scope.
+
+:::