Compare commits

...

15 Commits

Author SHA1 Message Date
Valentin Maerten
81fbca3420 refactor(compiler): remove unnecessary closure comments 2026-01-25 20:54:49 +01:00
Valentin Maerten
7323fe8009 fix: resolve lint issues after rebase
- Fix import order in setup.go (gci)
- Fix variable alignment in experiments.go (gofmt)
- Add nolint:paralleltest directive for TestScopedTaskfiles
2026-01-25 20:45:46 +01:00
Valentin Maerten
c8efbc2f4a docs(experiments): reference issue #2035 in scoped taskfiles doc 2026-01-25 19:54:04 +01:00
Valentin Maerten
17257a1c31 chore: add scoped variables planning documents (to be reverted) 2026-01-25 19:54:04 +01:00
Valentin Maerten
2810c267dd feat(scoped): refactor compiler, add nested includes, document flatten
Refactor compiler.go for better maintainability:
- Extract isScopedMode() helper function
- Split getVariables() into getScopedVariables() and getLegacyVariables()
- Fix directory resolution: parent chain env/vars now resolve from their
  own directory instead of the current task's directory

Add nested includes support and tests:
- Add testdata/scoped_taskfiles/inc_a/nested/Taskfile.yml (3 levels deep)
- Add test case for nested include inheritance (root → a → nested)
- Verify nested includes inherit vars from full parent chain

Fix flaky tests:
- Remove VAR from print tasks (defined in both inc_a and inc_b)
- Test only unique variables (UNIQUE_A, UNIQUE_B, ROOT_VAR)

Document flatten: true escape hatch:
- Add migration guide step for using flatten: true
- Add new section explaining flatten bypasses scoping
- Include example and usage recommendations
2026-01-25 19:54:04 +01:00
Valentin Maerten
a57a16efca fix(compiler): add call.Vars support in scoped mode
When calling a task with vars (e.g., `task: name` with `vars:`),
those vars were not being applied in scoped mode. This fix adds
call.Vars to the variable resolution chain.

Variable priority (lowest to highest):
1. Root Taskfile vars
2. Include Taskfile vars
3. Include passthrough vars
4. Task vars
5. Call vars (NEW)
6. CLI vars
2026-01-25 19:54:04 +01:00
Valentin Maerten
5ef7313e95 docs(experiments): add SCOPED_TASKFILES documentation
Document the new experiment with:
- Environment namespace ({{.env.XXX}}) explanation
- Variable scoping between includes
- CLI variables priority
- Migration guide from legacy mode
- Comparison table between legacy and scoped modes
2026-01-25 19:54:04 +01:00
Valentin Maerten
e05c9f7793 fix(compiler): CLI vars have highest priority in scoped mode
In scoped mode, CLI vars (e.g., `task foo VAR=value`) now correctly
override task-level vars. This is achieved by:

1. Adding a `CLIVars` field to the Compiler struct
2. Storing CLI globals in this field after parsing
3. Applying CLI vars last in scoped mode to ensure they override everything

The order of variable resolution in scoped mode is now:
1. OS env → {{.env.XXX}}
2. Root taskfile env → {{.env.XXX}}
3. Root taskfile vars → {{.VAR}}
4. Include taskfile env/vars (if applicable)
5. IncludeVars (vars passed via includes: section)
6. Task-level vars
7. CLI vars (highest priority)

Legacy mode behavior is unchanged.
2026-01-25 19:54:04 +01:00
Valentin Maerten
edee501b6b feat(experiments): rename SCOPED_INCLUDES to SCOPED_TASKFILES and add env namespace
Rename the experiment from SCOPED_INCLUDES to SCOPED_TASKFILES to better
reflect its expanded scope. This experiment now provides:

1. Variable scoping (existing): includes see only their own vars + parent vars
2. Environment namespace (new): env vars accessible via {{.env.XXX}}

With TASK_X_SCOPED_TASKFILES=1:
- {{.VAR}} accesses vars only (scoped per include)
- {{.env.VAR}} accesses env (OS + Taskfile env:, inherited)
- {{.TASK}} and other special vars remain at root level

This is a breaking change for the experimental feature:
- {{.PATH}} no longer works, use {{.env.PATH}} instead
- Env vars are no longer at root level in templates
2026-01-25 19:54:04 +01:00
Valentin Maerten
efaea39503 test(scoped-includes): add tests for variable isolation
Tests verify:
- Legacy mode: vars merged globally (A sees B's VAR, can access UNIQUE_B)
- Scoped mode: vars isolated (A sees own VAR, cannot access UNIQUE_B)
- Inheritance: includes can still access root vars (ROOT_VAR)

Test structure:
- testdata/scoped_includes/ with main Taskfile and two includes
- inc_a and inc_b both define VAR with different values
- Cross-include test shows A trying to access B's UNIQUE_B
2026-01-25 19:53:38 +01:00
Valentin Maerten
04b8b75525 feat(compiler): implement lazy variable resolution for scoped includes
When SCOPED_INCLUDES experiment is enabled:
- Resolve vars from DAG instead of merged vars
- Apply root Taskfile vars first (inheritance)
- Then apply task's source Taskfile vars from DAG
- Apply IncludeVars passed via includes: section
- Skip IncludedTaskfileVars (contains parent's vars, not source's)

This ensures tasks in included Taskfiles see:
1. Root vars (inheritance from parent)
2. Their own Taskfile's vars
3. Vars passed through includes: section
4. Call vars and task-level vars
2026-01-25 19:53:04 +01:00
Valentin Maerten
0dbeaaf187 feat(taskfile): skip var merge when SCOPED_INCLUDES enabled
When the SCOPED_INCLUDES experiment is enabled, variables from included
Taskfiles are no longer merged globally. They remain in their original
Taskfile within the DAG.

Exception: flatten includes still merge variables globally to allow
sharing common variables across multiple Taskfiles.
2026-01-25 19:53:04 +01:00
Valentin Maerten
da927ad5fe feat(graph): add Root() helper method
Add Root() method to TaskfileGraph to get the root vertex (entrypoint
Taskfile). This will be used for lazy variable resolution.

Note: Tasks already have Location.Taskfile which can be used to find
their source Taskfile in the graph, so GetVertexByNamespace is not
needed.
2026-01-25 19:49:16 +01:00
Valentin Maerten
9732f7e08b feat(executor): store TaskfileGraph for lazy resolution
Store the TaskfileGraph in the Executor so it can be used for lazy
variable resolution when SCOPED_INCLUDES experiment is enabled.

The graph is now preserved after reading, before merging into the
final Taskfile. This allows traversing the DAG at runtime to resolve
variables from the correct scope.
2026-01-25 19:49:16 +01:00
Valentin Maerten
1b418409d1 feat(experiments): add SCOPED_INCLUDES experiment
Add new experiment flag TASK_X_SCOPED_INCLUDES for scoped variable
resolution in included Taskfiles. When enabled, variables from included
Taskfiles will be isolated rather than merged globally.

This is the first step towards implementing lazy DAG-based variable
resolution with strict isolation between includes.
2026-01-25 19:49:16 +01:00
25 changed files with 829 additions and 18 deletions

View File

@@ -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)

View File

@@ -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) {
result := env.GetEnviron()
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
}

View File

@@ -63,6 +63,7 @@ type (
// Internal
Taskfile *ast.Taskfile
Graph *ast.TaskfileGraph
Logger *logger.Logger
Compiler *Compiler
Output output.Output

View File

@@ -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"),
)
})
}

View File

@@ -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)
}

View File

@@ -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

View File

@@ -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
}

View File

@@ -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)
}

57
testdata/scoped_taskfiles/Taskfile.yml vendored Normal file
View File

@@ -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}}"

View File

@@ -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}}"

View File

@@ -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}}"

View File

@@ -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}}"

View File

@@ -0,0 +1 @@
A:UNIQUE_B=only_in_b

View File

@@ -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

View File

@@ -0,0 +1 @@
NAME=from_caller

View File

@@ -0,0 +1 @@
A:UNIQUE_B=

View File

@@ -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

View File

@@ -0,0 +1,3 @@
ROOT_ENV=env_from_root
SHARED_ENV=shared_from_root
PATH_EXISTS=yes

View File

@@ -0,0 +1 @@
ROOT_ENV_AT_ROOT=

View File

@@ -0,0 +1,3 @@
A:INC_A_ENV=env_from_a
A:ROOT_ENV=env_from_root
A:SHARED_ENV=shared_from_a

View File

@@ -0,0 +1,2 @@
A:UNIQUE_A=only_in_a
A:ROOT_VAR=from_root

View File

@@ -0,0 +1,2 @@
B:UNIQUE_B=only_in_b
B:ROOT_VAR=from_root

View File

@@ -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

View File

@@ -302,6 +302,10 @@ export default defineConfig({
{
text: 'Remote Taskfiles (#1317)',
link: '/docs/experiments/remote-taskfiles'
},
{
text: 'Scoped Taskfiles',
link: '/docs/experiments/scoped-taskfiles'
}
]
},

View File

@@ -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: <span v-pre>`{{.PATH}}`</span>, <span v-pre>`{{.MY_ENV}}`</span>
- After: <span v-pre>`{{.env.PATH}}`</span>,
<span v-pre>`{{.env.MY_ENV}}`</span>
- **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 <span v-pre>`{{.env.XXX}}`</span>
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 <span v-pre>`{{.env.XXX}}`</span> namespace.
### Comparison Table
| Template | Legacy | SCOPED_TASKFILES |
| ----------------------------------------------- | ------ | ------------------------- |
| <span v-pre>`{{.MY_VAR}}`</span> (from `vars:`) | Works | Works |
| <span v-pre>`{{.MY_ENV}}`</span> (from `env:`) | Works | `<no value>` |
| <span v-pre>`{{.env.MY_ENV}}`</span> | - | Works |
| <span v-pre>`{{.PATH}}`</span> (OS) | Works | `<no value>` |
| <span v-pre>`{{.env.PATH}}`</span> (OS) | - | Works |
| <span v-pre>`{{.TASK}}`</span> (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:
- <span v-pre>`{{.PATH}}`</span> becomes
<span v-pre>`{{.env.PATH}}`</span>
- <span v-pre>`{{.HOME}}`</span> becomes
<span v-pre>`{{.env.HOME}}`</span>
- <span v-pre>`{{.MY_TASKFILE_ENV}}`</span> becomes
<span v-pre>`{{.env.MY_TASKFILE_ENV}}`</span>
2. **Variables in `vars:` sections remain unchanged**:
- <span v-pre>`{{.MY_VAR}}`</span> still works the same way
3. **Special variables stay at root level**:
- <span v-pre>`{{.TASK}}`</span>, <span v-pre>`{{.ROOT_DIR}}`</span>,
<span v-pre>`{{.TASKFILE}}`</span>, <span v-pre>`{{.TASKFILE_DIR}}`</span>,
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.
:::