Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 56 additions & 5 deletions requires.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"github.com/elliotchance/orderedmap/v3"

"github.com/go-task/task/v3/errors"
"github.com/go-task/task/v3/internal/env"
"github.com/go-task/task/v3/internal/input"
"github.com/go-task/task/v3/internal/templater"
"github.com/go-task/task/v3/internal/term"
Expand Down Expand Up @@ -46,7 +47,7 @@ func (e *Executor) promptDepsVars(calls []*Call) error {

for _, v := range getMissingRequiredVars(compiledTask) {
if !varsMap.Has(v.Name) {
varsMap.Set(v.Name, resolveEnumRefForPrompt(v, compiledTask.Vars))
varsMap.Set(v.Name, e.resolveEnumRefForPrompt(v, compiledTask.Vars, compiledTask.Dir))
}
}

Expand Down Expand Up @@ -220,13 +221,63 @@ func getEnumValues(e *ast.Enum) []string {

// resolveEnumRefForPrompt returns a copy of v with its enum ref resolved into
// concrete values, so the interactive prompter can show a Select. Refs that
// depend on dynamic vars may not resolve here and fall back to free-form input.
func resolveEnumRefForPrompt(v *ast.VarsWithValidation, vars *ast.Vars) *ast.VarsWithValidation {
// point to dynamic (sh:) variables are resolved by evaluating those variables
// first. Refs that still can't be resolved fall back to free-form input.
func (e *Executor) resolveEnumRefForPrompt(v *ast.VarsWithValidation, vars *ast.Vars, dir string) *ast.VarsWithValidation {
if v.Enum == nil || v.Enum.Ref == "" || len(v.Enum.Value) > 0 {
return v
}
// Refs may depend on dynamic (sh:) variables that are not evaluated in the
// fast-compiled vars (they resolve to an empty value there). Evaluate them
// first so the prompt can show a selection list instead of falling back to
// free-form input.
if hasDynamicVars(vars) {
if evaluated := e.evaluateDynamicVarsForPrompt(vars, dir); evaluated != nil {
vars = evaluated
}
}
vCopy := v.DeepCopy()
cache := &templater.Cache{Vars: vars}
_ = resolveEnumRefs(&ast.Requires{Vars: []*ast.VarsWithValidation{vCopy}}, cache)
_ = resolveEnumRefs(&ast.Requires{Vars: []*ast.VarsWithValidation{vCopy}}, &templater.Cache{Vars: vars})
return vCopy
}

// hasDynamicVars reports whether any variable in vars is backed by a shell
// command (sh:) that has not been evaluated yet.
func hasDynamicVars(vars *ast.Vars) bool {
if vars == nil {
return false
}
for v := range vars.Values() {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should this be for _, v ...

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

and if wrong, perhaps why the unit test does not fail.

@ikshantshukla123 ikshantshukla123 Aug 15, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

and if wrong, perhaps why the unit test does not fail.

Good question vars.Values() returns iter.Seq[Var] (a single-value iterator, not (k, v) pairs), so for v := range vars.Values() yields each Var directly. for _, v := range wouldn't compile here since iter.Seq[Var] ranges over exactly one variable.
On the test question it's guarded by TestPromptEnumRefResolution — if hasDynamicVars returned false for the dynamic sh: fixture, the enum would come back empty and that test would fail. (And hasDynamicVars is really just a cheap guard; evaluateDynamicVarsForPrompt handles the no-op case fine either way.)

if v.Sh != nil && *v.Sh != "" {
return true
}
}
return false
}

// evaluateDynamicVarsForPrompt evaluates the dynamic (sh:) variables in vars,
// returning a new Vars map with their resolved values. Individual commands that
// fail are left unevaluated so the remaining variables can still be resolved.
func (e *Executor) evaluateDynamicVarsForPrompt(vars *ast.Vars, dir string) *ast.Vars {
if e == nil || e.Compiler == nil || vars == nil {
return nil
}
result := ast.NewVars()
for k, v := range vars.All() {
cache := &templater.Cache{Vars: result}
newVar := templater.ReplaceVar(v, cache)
if newVar.Value != nil && (newVar.Sh == nil || *newVar.Sh == "") {
result.Set(k, ast.Var{Value: newVar.Value, Secret: v.Secret})
continue
}
static, err := e.Compiler.HandleDynamicVar(newVar, dir, env.GetFromVars(result))
if err != nil {
// Leave the variable unevaluated if the command fails, so the rest
// of the variables can still be evaluated.
result.Set(k, ast.Var{Value: "", Secret: v.Secret})
continue
}
result.Set(k, ast.Var{Value: static, Secret: v.Secret})
}
return result
}
134 changes: 128 additions & 6 deletions requires_internal_test.go
Original file line number Diff line number Diff line change
@@ -1,44 +1,166 @@
package task

import (
"io"
"testing"

"github.com/stretchr/testify/require"

"github.com/go-task/task/v3/internal/logger"
"github.com/go-task/task/v3/taskfile/ast"
)

// newTestPromptExecutor returns an Executor whose Compiler is wired up so that
// dynamic (sh:) variables can be evaluated during enum ref resolution.
func newTestPromptExecutor(t *testing.T) *Executor {
t.Helper()
e := NewExecutor(WithAssumeTerm(true))
e.Compiler = &Compiler{
Dir: t.TempDir(),
TaskfileEnv: ast.NewVars(),
TaskfileVars: ast.NewVars(),
Logger: &logger.Logger{Stderr: io.Discard},
}
return e
}

// TestPromptEnumRefResolution exercises the real prompt path: fast-compiled
// task vars are resolved through resolveEnumRefForPrompt, exactly as
// promptDepsVars does, against a real Taskfile fixture.
func TestPromptEnumRefResolution(t *testing.T) {
t.Parallel()

e := NewExecutor(WithDir("testdata/enum_ref_prompt"), WithAssumeTerm(true))
require.NoError(t, e.Setup())

tests := []struct {
name string
task string
varName string
wantEnum []string
}{
{
name: "enum ref to a dynamic sh variable is evaluated",
task: "deploy",
varName: "SERVICE",
wantEnum: []string{"api", "web", "db"},
},
{
name: "enum ref to a static list variable resolves",
task: "release",
varName: "ENV",
wantEnum: []string{"dev", "staging", "prod"},
},
{
name: "enum ref to an empty dynamic variable yields no options",
task: "deploy-empty",
varName: "SERVICE",
// resolveEnumRefs always assigns a (possibly empty) slice, which is
// what keeps the prompter on free-form input.
wantEnum: []string{},
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()

compiledTask, err := e.FastCompiledTask(&Call{Task: tt.task})
require.NoError(t, err)

missing := getMissingRequiredVars(compiledTask)
require.Len(t, missing, 1)
require.Equal(t, tt.varName, missing[0].Name)

resolved := e.resolveEnumRefForPrompt(missing[0], compiledTask.Vars, compiledTask.Dir)

require.Equal(t, tt.wantEnum, getEnumValues(resolved.Enum))
require.Empty(t, missing[0].Enum.Value, "input var must not be mutated")
})
}
}

func TestResolveEnumRefForPrompt(t *testing.T) {
t.Parallel()

vars := ast.NewVars()
vars.Set("ALLOWED_ENVS", ast.Var{Value: []any{"dev", "staging", "prod"}})
e := newTestPromptExecutor(t)
dir := e.Compiler.Dir

t.Run("resolves a static ref into values", func(t *testing.T) {
t.Parallel()

vars := ast.NewVars()
vars.Set("ALLOWED_ENVS", ast.Var{Value: []any{"dev", "staging", "prod"}})

v := &ast.VarsWithValidation{Name: "ENV", Enum: &ast.Enum{Ref: ".ALLOWED_ENVS"}}

resolved := resolveEnumRefForPrompt(v, vars)
resolved := e.resolveEnumRefForPrompt(v, vars, dir)

require.Equal(t, []string{"dev", "staging", "prod"}, getEnumValues(resolved.Enum))
require.Empty(t, v.Enum.Value, "input var must not be mutated")
require.Equal(t, ".ALLOWED_ENVS", v.Enum.Ref)
})

t.Run("leaves an unresolvable ref as-is", func(t *testing.T) {
t.Parallel()

vars := ast.NewVars()
vars.Set("ALLOWED_ENVS", ast.Var{Value: []any{"dev", "staging", "prod"}})

v := &ast.VarsWithValidation{Name: "ENV", Enum: &ast.Enum{Ref: ".NONEXISTENT"}}

require.Empty(t, getEnumValues(resolveEnumRefForPrompt(v, vars).Enum))
require.Empty(t, getEnumValues(e.resolveEnumRefForPrompt(v, vars, dir).Enum))
})

t.Run("passes through a static enum unchanged", func(t *testing.T) {
t.Parallel()

v := &ast.VarsWithValidation{Name: "ENV", Enum: &ast.Enum{Value: []string{"a", "b"}}}

require.Same(t, v, resolveEnumRefForPrompt(v, vars))
require.Same(t, v, e.resolveEnumRefForPrompt(v, ast.NewVars(), dir))
})

t.Run("resolves a ref to a dynamic sh variable", func(t *testing.T) {
t.Parallel()

// FastGetVariables stores un-evaluated dynamic vars as {Value: "", Sh: ...}.
// The sh command must still be evaluated so the ref resolves.
fastVars := ast.NewVars()
fastVars.Set("AVAILABLE_SERVICES", ast.Var{Value: "", Sh: strPtr("printf 'api\nweb\ndb\n'")})

v := &ast.VarsWithValidation{Name: "SERVICE", Enum: &ast.Enum{Ref: ".AVAILABLE_SERVICES | splitLines | compact"}}

resolved := e.resolveEnumRefForPrompt(v, fastVars, dir)

require.Equal(t, []string{"api", "web", "db"}, getEnumValues(resolved.Enum))
require.Empty(t, v.Enum.Value, "input var must not be mutated")
})

t.Run("keeps free-form fallback when a dynamic ref is empty", func(t *testing.T) {
t.Parallel()

fastVars := ast.NewVars()
fastVars.Set("AVAILABLE_SERVICES", ast.Var{Value: "", Sh: strPtr("printf ''")})

v := &ast.VarsWithValidation{Name: "SERVICE", Enum: &ast.Enum{Ref: ".AVAILABLE_SERVICES | splitLines | compact"}}

require.Empty(t, getEnumValues(e.resolveEnumRefForPrompt(v, fastVars, dir).Enum))
})

t.Run("resolves a dynamic ref even when an unrelated sh var fails", func(t *testing.T) {
t.Parallel()

fastVars := ast.NewVars()
fastVars.Set("BROKEN", ast.Var{Value: "", Sh: strPtr("exit 1")})
fastVars.Set("AVAILABLE_SERVICES", ast.Var{Value: "", Sh: strPtr("printf 'api\nweb\ndb\n'")})

v := &ast.VarsWithValidation{Name: "SERVICE", Enum: &ast.Enum{Ref: ".AVAILABLE_SERVICES | splitLines | compact"}}

resolved := e.resolveEnumRefForPrompt(v, fastVars, dir)

require.Equal(t, []string{"api", "web", "db"}, getEnumValues(resolved.Enum))
})
}

func strPtr(s string) *string {
return &s
}
42 changes: 42 additions & 0 deletions testdata/enum_ref_prompt/Taskfile.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
version: '3'

vars:
AVAILABLE_SERVICES:
sh: printf 'api\nweb\ndb\n'
ALLOWED_ENVS:
- dev
- staging
- prod
NO_SERVICES:
sh: printf ''

tasks:
deploy:
desc: Deploy a service chosen interactively from a dynamic sh variable
requires:
vars:
- name: SERVICE
enum:
ref: .AVAILABLE_SERVICES | splitLines | compact
cmds:
- echo "Deploying {{.SERVICE}}"

release:
desc: Release to an environment chosen from a static list var
requires:
vars:
- name: ENV
enum:
ref: .ALLOWED_ENVS
cmds:
- echo "Releasing to {{.ENV}}"

deploy-empty:
desc: Dynamic sh variable produces no values, falls back to free-form input
requires:
vars:
- name: SERVICE
enum:
ref: .NO_SERVICES | splitLines | compact
cmds:
- echo "Deploying {{.SERVICE}}"