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. + +:::