Compare commits

..

2 Commits

Author SHA1 Message Date
Valentin Maerten
9a96a47c99 style(ast): fix Ask field alignment in Cmd struct 2026-02-21 10:48:10 +01:00
Valentin Maerten
93cdccefce feat(cmd): add ask option to commands
Add an `ask` attribute to commands that shows a y/n confirmation
before executing. If the user declines, the command is skipped but
the task continues with the next command.

Example:
```yaml
cmds:
  - cmd: echo "Deploying..."
    ask: "Deploy to production?"
  - task: run-migrations
    ask: "Run database migrations?"
```

Behavior:
- Default: asks for y/n confirmation
- --yes: auto-confirms all asks
- --dry: shows commands without asking

This differs from task-level `prompt:` which cancels the entire task
if declined. Command-level `ask:` only skips the individual command.
2026-01-29 19:02:44 +01:00
29 changed files with 78 additions and 828 deletions

View File

@@ -174,8 +174,6 @@ 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,7 +9,6 @@ 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"
@@ -26,8 +25,6 @@ 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
@@ -47,236 +44,8 @@ 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
@@ -288,22 +57,30 @@ func (c *Compiler) getLegacyVariables(t *ast.Task, call *Call, evaluateShVars bo
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
@@ -316,6 +93,8 @@ func (c *Compiler) getLegacyVariables(t *ast.Task, call *Call, evaluateShVars bo
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 {
@@ -335,7 +114,6 @@ func (c *Compiler) getLegacyVariables(t *ast.Task, call *Call, evaluateShVars bo
return nil, err
}
}
if t != nil {
for k, v := range t.IncludeVars.All() {
if err := rangeFunc(k, v); err != nil {
@@ -371,6 +149,7 @@ 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,7 +63,6 @@ type (
// Internal
Taskfile *ast.Taskfile
Graph *ast.TaskfileGraph
Logger *logger.Logger
Compiler *Compiler
Output output.Output

View File

@@ -1180,114 +1180,3 @@ 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,7 +19,6 @@ var (
GentleForce Experiment
RemoteTaskfiles Experiment
EnvPrecedence Experiment
ScopedTaskfiles Experiment
)
// Inactive experiments. These are experiments that cannot be enabled, but are
@@ -44,7 +43,6 @@ 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,7 +13,6 @@ 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"
@@ -105,8 +104,7 @@ func (e *Executor) readTaskfile(node taskfile.Node) error {
}
return err
}
e.Graph = graph
if e.Taskfile, err = graph.Merge(experiments.ScopedTaskfiles.Enabled()); err != nil {
if e.Taskfile, err = graph.Merge(); err != nil {
return err
}
return nil
@@ -228,7 +226,6 @@ func (e *Executor) setupCompiler() error {
UserWorkingDir: e.UserWorkingDir,
TaskfileEnv: e.Taskfile.Env,
TaskfileVars: e.Taskfile.Vars,
Graph: e.Graph,
Logger: e.Logger,
}
return nil

16
task.go
View File

@@ -365,6 +365,22 @@ func (e *Executor) runCommand(ctx context.Context, t *ast.Task, call *Call, i in
}
}
// Handle ask attached to command (y/n confirmation)
if cmd.Ask != "" && !e.Dry {
if e.AssumeYes {
e.Logger.VerboseOutf(logger.Yellow, "task: [%s] %s [assuming yes]\n", t.Name(), cmd.Ask)
} else {
if err := e.Logger.Prompt(logger.Yellow, cmd.Ask, "n", "y", "yes"); errors.Is(err, logger.ErrNoTerminal) {
return &errors.TaskCancelledNoTerminalError{TaskName: call.Task}
} else if errors.Is(err, logger.ErrPromptCancelled) {
e.Logger.VerboseOutf(logger.Yellow, "task: [%s] ask declined - skipped\n", t.Name())
return nil
} else if err != nil {
return err
}
}
}
switch {
case cmd.Task != "":
reacquire := e.releaseConcurrencyLimit()

View File

@@ -20,6 +20,7 @@ type Cmd struct {
IgnoreError bool
Defer bool
Platforms []*Platform
Ask string
}
func (c *Cmd) DeepCopy() *Cmd {
@@ -38,6 +39,7 @@ func (c *Cmd) DeepCopy() *Cmd {
IgnoreError: c.IgnoreError,
Defer: c.Defer,
Platforms: deepcopy.Slice(c.Platforms),
Ask: c.Ask,
}
}
@@ -65,6 +67,7 @@ func (c *Cmd) UnmarshalYAML(node *yaml.Node) error {
IgnoreError bool `yaml:"ignore_error"`
Defer *Defer
Platforms []*Platform
Ask string
}
if err := node.Decode(&cmdStruct); err != nil {
return errors.NewTaskfileDecodeError(err, node)
@@ -98,6 +101,7 @@ func (c *Cmd) UnmarshalYAML(node *yaml.Node) error {
c.If = cmdStruct.If
c.Silent = cmdStruct.Silent
c.IgnoreError = cmdStruct.IgnoreError
c.Ask = cmdStruct.Ask
return nil
}
@@ -111,6 +115,7 @@ func (c *Cmd) UnmarshalYAML(node *yaml.Node) error {
c.Shopt = cmdStruct.Shopt
c.IgnoreError = cmdStruct.IgnoreError
c.Platforms = cmdStruct.Platforms
c.Ask = cmdStruct.Ask
return nil
}

View File

@@ -45,21 +45,7 @@ func (tfg *TaskfileGraph) Visualize(filename string) error {
return draw.DOT(tfg.Graph, f)
}
// 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) {
func (tfg *TaskfileGraph) Merge() (*Taskfile, error) {
hashes, err := graph.TopologicalSort(tfg.Graph)
if err != nil {
return nil, err
@@ -106,7 +92,6 @@ func (tfg *TaskfileGraph) Merge(skipVarsMerge bool) (*Taskfile, error) {
if err := vertex.Taskfile.Merge(
includedVertex.Taskfile,
include,
skipVarsMerge,
); err != nil {
return err
}

View File

@@ -36,9 +36,8 @@ type Taskfile struct {
Interval time.Duration
}
// 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 {
// Merge merges the second Taskfile into the first
func (t1 *Taskfile) Merge(t2 *Taskfile, include *Include) 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)
}
@@ -68,11 +67,8 @@ func (t1 *Taskfile) Merge(t2 *Taskfile, include *Include, skipVarsMerge bool) er
}
}
}
// 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)
}
t1.Vars.Merge(t2.Vars, include)
t1.Env.Merge(t2.Env, include)
return t1.Tasks.Merge(t2.Tasks, include, t1.Vars)
}

View File

@@ -1,57 +0,0 @@
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

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

@@ -1,22 +0,0 @@
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

@@ -1,23 +0,0 @@
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

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

View File

@@ -1,4 +0,0 @@
A:UNIQUE_A=only_in_a
A:ROOT_VAR=from_root
B:UNIQUE_B=only_in_b
B:ROOT_VAR=from_root

View File

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

View File

@@ -1,4 +0,0 @@
A:UNIQUE_A=only_in_a
A:ROOT_VAR=from_root
B:UNIQUE_B=only_in_b
B:ROOT_VAR=from_root

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,5 +0,0 @@
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,10 +302,6 @@ export default defineConfig({
{
text: 'Remote Taskfiles (#1317)',
link: '/docs/experiments/remote-taskfiles'
},
{
text: 'Scoped Taskfiles',
link: '/docs/experiments/scoped-taskfiles'
}
]
},

View File

@@ -1,281 +0,0 @@
---
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.
:::

View File

@@ -741,6 +741,8 @@ tasks:
platforms: [linux, darwin]
set: [errexit]
shopt: [globstar]
if: '[ "$CI" = "true" ]'
ask: "Run this command?"
```
### Task References
@@ -857,6 +859,36 @@ tasks:
if: '[ "{{.ITEM}}" != "b" ]'
```
### Command Confirmations
Use `ask` to request user confirmation before executing a command. If the
user declines (answers "n" or "no"), the command is skipped but the task
continues.
```yaml
tasks:
deploy:
cmds:
- cmd: echo "Deploying to production..."
ask: "Deploy to production?"
- cmd: echo "Updating database..."
ask: "Run database migrations?"
- echo "Done!" # No ask, always runs
```
| Flag | Behavior |
|------|----------|
| (none) | Asks user for y/n confirmation |
| `--yes` | Auto-confirms all asks |
| `--dry` | Shows commands without asking |
:::note
This is different from the task-level `prompt:` which cancels the entire task
if declined. Command-level `ask:` only skips the individual command.
:::
## Shell Options
### Set Options

View File

@@ -340,6 +340,10 @@
"if": {
"description": "A shell command to evaluate. If the exit code is non-zero, the command is skipped.",
"type": "string"
},
"ask": {
"description": "A y/n confirmation shown before executing this task call. If declined, the task call is skipped.",
"type": "string"
}
},
"additionalProperties": false,
@@ -381,6 +385,10 @@
"if": {
"description": "A shell command to evaluate. If the exit code is non-zero, the command is skipped.",
"type": "string"
},
"ask": {
"description": "A y/n confirmation shown before executing this command. If declined, the command is skipped.",
"type": "string"
}
},
"additionalProperties": false,