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
This commit is contained in:
Valentin Maerten
2025-12-29 16:31:51 +01:00
parent efaea39503
commit edee501b6b
19 changed files with 212 additions and 74 deletions

View File

@@ -47,7 +47,16 @@ func (c *Compiler) FastGetVariables(t *ast.Task, call *Call) (*ast.Vars, error)
}
func (c *Compiler) getVariables(t *ast.Task, call *Call, evaluateShVars bool) (*ast.Vars, error) {
result := env.GetEnviron()
// In scoped mode, OS env vars are in {{.env.XXX}} namespace, not at root
// In legacy mode, they are at root level
scopedMode := experiments.ScopedTaskfiles.Enabled() && t != nil && t.Location != nil && c.Graph != nil
var result *ast.Vars
if scopedMode {
result = ast.NewVars()
} else {
result = env.GetEnviron()
}
specialVars, err := c.getSpecialVars(t, call)
if err != nil {
return nil, err
@@ -107,20 +116,64 @@ func (c *Compiler) getVariables(t *ast.Task, call *Call, evaluateShVars bool) (*
}
// When scoped includes is enabled, resolve vars from DAG instead of merged vars
if experiments.ScopedIncludes.Enabled() && t != nil && t.Location != nil && c.Graph != nil {
if scopedMode {
// Get root Taskfile for inheritance (parent vars are always accessible)
rootVertex, err := c.Graph.Root()
if err != nil {
return nil, err
}
// Apply root env
// === ENV NAMESPACE ===
// Create a separate map for environment variables
// Accessible via {{.env.VAR}} in templates
envMap := make(map[string]any)
// 1. OS environment variables
for _, e := range os.Environ() {
k, v, _ := strings.Cut(e, "=")
envMap[k] = v
}
// Helper to resolve env vars and add to envMap
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
}
// Static value
if newVar.Value != nil || newVar.Sh == nil {
if newVar.Value != nil {
envMap[k] = newVar.Value
}
return nil
}
// Dynamic value (sh:)
if evaluateShVars {
// Build env slice for sh execution (includes envMap values)
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
}
// 2. Root taskfile env
for k, v := range rootVertex.Taskfile.Env.All() {
if err := rangeFunc(k, v); err != nil {
if err := resolveEnvToMap(k, v, c.Dir); err != nil {
return nil, err
}
}
// === VARS (at root level) ===
// Apply root vars
for k, v := range rootVertex.Taskfile.Vars.All() {
if err := rangeFunc(k, v); err != nil {
@@ -134,9 +187,9 @@ func (c *Compiler) getVariables(t *ast.Task, call *Call, evaluateShVars bool) (*
if err != nil {
return nil, err
}
// Apply include's env (overrides root's env)
// Apply include's env to envMap (overrides root's env)
for k, v := range includeVertex.Taskfile.Env.All() {
if err := taskRangeFunc(k, v); err != nil {
if err := resolveEnvToMap(k, v, filepathext.SmartJoin(c.Dir, t.Dir)); err != nil {
return nil, err
}
}
@@ -156,6 +209,9 @@ func (c *Compiler) getVariables(t *ast.Task, call *Call, evaluateShVars bool) (*
}
}
}
// Inject env namespace into result
result.Set("env", ast.Var{Value: envMap})
} else {
// Legacy behavior: use merged vars
for k, v := range c.TaskfileEnv.All() {

View File

@@ -1181,16 +1181,17 @@ func TestIf(t *testing.T) {
}
}
func TestScopedIncludes(t *testing.T) {
t.Parallel()
func TestScopedTaskfiles(t *testing.T) {
// NOTE: Don't use t.Parallel() here because enableExperimentForTest modifies
// global state that can affect other tests running in parallel.
// Legacy tests (without experiment) - vars should be merged globally
t.Run("legacy", func(t *testing.T) {
// Test with scoped includes disabled (legacy) - vars should be merged globally
// Test with scoped taskfiles disabled (legacy) - vars should be merged globally
NewExecutorTest(t,
WithName("default"),
WithExecutorOptions(
task.WithDir("testdata/scoped_includes"),
task.WithDir("testdata/scoped_taskfiles"),
task.WithSilent(true),
),
)
@@ -1198,7 +1199,7 @@ func TestScopedIncludes(t *testing.T) {
NewExecutorTest(t,
WithName("cross-include"),
WithExecutorOptions(
task.WithDir("testdata/scoped_includes"),
task.WithDir("testdata/scoped_taskfiles"),
task.WithSilent(true),
),
WithTask("a:try-access-b"),
@@ -1207,13 +1208,13 @@ func TestScopedIncludes(t *testing.T) {
// Scoped tests (with experiment enabled) - vars should be isolated
t.Run("scoped", func(t *testing.T) {
enableExperimentForTest(t, &experiments.ScopedIncludes, 1)
enableExperimentForTest(t, &experiments.ScopedTaskfiles, 1)
// Test with scoped includes enabled - vars should be isolated
// Test with scoped taskfiles enabled - vars should be isolated
NewExecutorTest(t,
WithName("default"),
WithExecutorOptions(
task.WithDir("testdata/scoped_includes"),
task.WithDir("testdata/scoped_taskfiles"),
task.WithSilent(true),
),
)
@@ -1221,7 +1222,7 @@ func TestScopedIncludes(t *testing.T) {
NewExecutorTest(t,
WithName("inheritance-a"),
WithExecutorOptions(
task.WithDir("testdata/scoped_includes"),
task.WithDir("testdata/scoped_taskfiles"),
task.WithSilent(true),
),
WithTask("a:print"),
@@ -1230,7 +1231,7 @@ func TestScopedIncludes(t *testing.T) {
NewExecutorTest(t,
WithName("isolation-b"),
WithExecutorOptions(
task.WithDir("testdata/scoped_includes"),
task.WithDir("testdata/scoped_taskfiles"),
task.WithSilent(true),
),
WithTask("b:print"),
@@ -1239,10 +1240,37 @@ func TestScopedIncludes(t *testing.T) {
NewExecutorTest(t,
WithName("cross-include"),
WithExecutorOptions(
task.WithDir("testdata/scoped_includes"),
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"),
)
})
}

View File

@@ -16,10 +16,10 @@ const envPrefix = "TASK_X_"
// Active experiments.
var (
GentleForce Experiment
RemoteTaskfiles Experiment
EnvPrecedence Experiment
ScopedIncludes Experiment
GentleForce Experiment
RemoteTaskfiles Experiment
EnvPrecedence Experiment
ScopedTaskfiles Experiment
)
// Inactive experiments. These are experiments that cannot be enabled, but are
@@ -44,7 +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)
ScopedIncludes = New("SCOPED_INCLUDES", config, 1)
ScopedTaskfiles = New("SCOPED_TASKFILES", config, 1)
AnyVariables = New("ANY_VARIABLES", config)
MapVariables = New("MAP_VARIABLES", config)
}

View File

@@ -106,7 +106,7 @@ func (e *Executor) readTaskfile(node taskfile.Node) error {
return err
}
e.Graph = graph
if e.Taskfile, err = graph.Merge(experiments.ScopedIncludes.Enabled()); err != nil {
if e.Taskfile, err = graph.Merge(experiments.ScopedTaskfiles.Enabled()); err != nil {
return err
}
return nil

View File

@@ -1,20 +0,0 @@
version: "3"
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}}"

View File

@@ -1,18 +0,0 @@
version: "3"
vars:
VAR: value_from_a
UNIQUE_A: only_in_a
tasks:
print:
desc: Print vars from include A
cmds:
- echo "A:VAR={{.VAR}}"
- 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}}"

View File

@@ -1,13 +0,0 @@
version: "3"
vars:
VAR: value_from_b
UNIQUE_B: only_in_b
tasks:
print:
desc: Print vars from include B
cmds:
- echo "B:VAR={{.VAR}}"
- echo "B:UNIQUE_B={{.UNIQUE_B}}"
- echo "B:ROOT_VAR={{.ROOT_VAR}}"

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

@@ -0,0 +1,38 @@
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}}"

View File

@@ -0,0 +1,36 @@
version: "3"
env:
INC_A_ENV: env_from_a
SHARED_ENV: shared_from_a
vars:
VAR: value_from_a
UNIQUE_A: only_in_a
tasks:
print:
desc: Print vars from include A
cmds:
- echo "A:VAR={{.VAR}}"
- 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,24 @@
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:VAR={{.VAR}}"
- 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,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