mirror of
https://github.com/go-task/task.git
synced 2026-05-18 13:15:41 +02:00
Compare commits
15 Commits
fix/specia
...
scoped
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
81fbca3420 | ||
|
|
7323fe8009 | ||
|
|
c8efbc2f4a | ||
|
|
17257a1c31 | ||
|
|
2810c267dd | ||
|
|
a57a16efca | ||
|
|
5ef7313e95 | ||
|
|
e05c9f7793 | ||
|
|
edee501b6b | ||
|
|
efaea39503 | ||
|
|
04b8b75525 | ||
|
|
0dbeaaf187 | ||
|
|
da927ad5fe | ||
|
|
9732f7e08b | ||
|
|
1b418409d1 |
@@ -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)
|
||||
|
||||
245
compiler.go
245
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) {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -63,6 +63,7 @@ type (
|
||||
|
||||
// Internal
|
||||
Taskfile *ast.Taskfile
|
||||
Graph *ast.TaskfileGraph
|
||||
Logger *logger.Logger
|
||||
Compiler *Compiler
|
||||
Output output.Output
|
||||
|
||||
111
executor_test.go
111
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"),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
5
setup.go
5
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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
57
testdata/scoped_taskfiles/Taskfile.yml
vendored
Normal 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}}"
|
||||
38
testdata/scoped_taskfiles/inc_a/Taskfile.yml
vendored
Normal file
38
testdata/scoped_taskfiles/inc_a/Taskfile.yml
vendored
Normal 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}}"
|
||||
22
testdata/scoped_taskfiles/inc_a/nested/Taskfile.yml
vendored
Normal file
22
testdata/scoped_taskfiles/inc_a/nested/Taskfile.yml
vendored
Normal 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}}"
|
||||
23
testdata/scoped_taskfiles/inc_b/Taskfile.yml
vendored
Normal file
23
testdata/scoped_taskfiles/inc_b/Taskfile.yml
vendored
Normal 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}}"
|
||||
1
testdata/scoped_taskfiles/testdata/TestScopedTaskfiles-legacy-cross-include.golden
vendored
Normal file
1
testdata/scoped_taskfiles/testdata/TestScopedTaskfiles-legacy-cross-include.golden
vendored
Normal file
@@ -0,0 +1 @@
|
||||
A:UNIQUE_B=only_in_b
|
||||
4
testdata/scoped_taskfiles/testdata/TestScopedTaskfiles-legacy-default.golden
vendored
Normal file
4
testdata/scoped_taskfiles/testdata/TestScopedTaskfiles-legacy-default.golden
vendored
Normal 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
|
||||
1
testdata/scoped_taskfiles/testdata/TestScopedTaskfiles-scoped-call-vars.golden
vendored
Normal file
1
testdata/scoped_taskfiles/testdata/TestScopedTaskfiles-scoped-call-vars.golden
vendored
Normal file
@@ -0,0 +1 @@
|
||||
NAME=from_caller
|
||||
1
testdata/scoped_taskfiles/testdata/TestScopedTaskfiles-scoped-cross-include.golden
vendored
Normal file
1
testdata/scoped_taskfiles/testdata/TestScopedTaskfiles-scoped-cross-include.golden
vendored
Normal file
@@ -0,0 +1 @@
|
||||
A:UNIQUE_B=
|
||||
4
testdata/scoped_taskfiles/testdata/TestScopedTaskfiles-scoped-default.golden
vendored
Normal file
4
testdata/scoped_taskfiles/testdata/TestScopedTaskfiles-scoped-default.golden
vendored
Normal 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
|
||||
3
testdata/scoped_taskfiles/testdata/TestScopedTaskfiles-scoped-env-namespace.golden
vendored
Normal file
3
testdata/scoped_taskfiles/testdata/TestScopedTaskfiles-scoped-env-namespace.golden
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
ROOT_ENV=env_from_root
|
||||
SHARED_ENV=shared_from_root
|
||||
PATH_EXISTS=yes
|
||||
1
testdata/scoped_taskfiles/testdata/TestScopedTaskfiles-scoped-env-separation.golden
vendored
Normal file
1
testdata/scoped_taskfiles/testdata/TestScopedTaskfiles-scoped-env-separation.golden
vendored
Normal file
@@ -0,0 +1 @@
|
||||
ROOT_ENV_AT_ROOT=
|
||||
3
testdata/scoped_taskfiles/testdata/TestScopedTaskfiles-scoped-include-env.golden
vendored
Normal file
3
testdata/scoped_taskfiles/testdata/TestScopedTaskfiles-scoped-include-env.golden
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
A:INC_A_ENV=env_from_a
|
||||
A:ROOT_ENV=env_from_root
|
||||
A:SHARED_ENV=shared_from_a
|
||||
2
testdata/scoped_taskfiles/testdata/TestScopedTaskfiles-scoped-inheritance-a.golden
vendored
Normal file
2
testdata/scoped_taskfiles/testdata/TestScopedTaskfiles-scoped-inheritance-a.golden
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
A:UNIQUE_A=only_in_a
|
||||
A:ROOT_VAR=from_root
|
||||
2
testdata/scoped_taskfiles/testdata/TestScopedTaskfiles-scoped-isolation-b.golden
vendored
Normal file
2
testdata/scoped_taskfiles/testdata/TestScopedTaskfiles-scoped-isolation-b.golden
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
B:UNIQUE_B=only_in_b
|
||||
B:ROOT_VAR=from_root
|
||||
5
testdata/scoped_taskfiles/testdata/TestScopedTaskfiles-scoped-nested.golden
vendored
Normal file
5
testdata/scoped_taskfiles/testdata/TestScopedTaskfiles-scoped-nested.golden
vendored
Normal 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
|
||||
@@ -302,6 +302,10 @@ export default defineConfig({
|
||||
{
|
||||
text: 'Remote Taskfiles (#1317)',
|
||||
link: '/docs/experiments/remote-taskfiles'
|
||||
},
|
||||
{
|
||||
text: 'Scoped Taskfiles',
|
||||
link: '/docs/experiments/scoped-taskfiles'
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
281
website/src/docs/experiments/scoped-taskfiles.md
Normal file
281
website/src/docs/experiments/scoped-taskfiles.md
Normal 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.
|
||||
|
||||
:::
|
||||
Reference in New Issue
Block a user