mirror of
https://github.com/go-task/task.git
synced 2026-04-24 19:54:16 +02:00
f09f31c6d5
Move the prompt for required variables AFTER the if condition check. This avoids asking the user for input when the task won't run anyway. The order in RunTask() is now: 1. FastCompiledTask 2. Check required vars early (non-interactive mode only) 3. CompiledTask (resolve dynamic vars) 4. Check if condition → exit early if false 5. Prompt for missing vars (only if task will run) 6. Validate required vars
617 lines
16 KiB
Go
617 lines
16 KiB
Go
package task
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"os"
|
|
"runtime"
|
|
"slices"
|
|
"strings"
|
|
"sync/atomic"
|
|
|
|
"golang.org/x/sync/errgroup"
|
|
"mvdan.cc/sh/v3/interp"
|
|
|
|
"github.com/go-task/task/v3/errors"
|
|
"github.com/go-task/task/v3/internal/env"
|
|
"github.com/go-task/task/v3/internal/execext"
|
|
"github.com/go-task/task/v3/internal/fingerprint"
|
|
"github.com/go-task/task/v3/internal/logger"
|
|
"github.com/go-task/task/v3/internal/output"
|
|
"github.com/go-task/task/v3/internal/slicesext"
|
|
"github.com/go-task/task/v3/internal/sort"
|
|
"github.com/go-task/task/v3/internal/summary"
|
|
"github.com/go-task/task/v3/internal/templater"
|
|
"github.com/go-task/task/v3/taskfile/ast"
|
|
)
|
|
|
|
const (
|
|
// MaximumTaskCall is the max number of times a task can be called.
|
|
// This exists to prevent infinite loops on cyclic dependencies
|
|
MaximumTaskCall = 1000
|
|
)
|
|
|
|
// MatchingTask represents a task that matches a given call. It includes the
|
|
// task itself and a list of wildcards that were matched.
|
|
type MatchingTask struct {
|
|
Task *ast.Task
|
|
Wildcards []string
|
|
}
|
|
|
|
// Run runs Task
|
|
func (e *Executor) Run(ctx context.Context, calls ...*Call) error {
|
|
// check if given tasks exist
|
|
for _, call := range calls {
|
|
task, err := e.GetTask(call)
|
|
if err != nil {
|
|
if _, ok := err.(*errors.TaskNotFoundError); ok {
|
|
if _, err := e.ListTasks(ListOptions{ListOnlyTasksWithDescriptions: true}); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return err
|
|
}
|
|
|
|
if task.Internal {
|
|
if _, ok := err.(*errors.TaskNotFoundError); ok {
|
|
if _, err := e.ListTasks(ListOptions{ListOnlyTasksWithDescriptions: true}); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return &errors.TaskInternalError{TaskName: call.Task}
|
|
}
|
|
}
|
|
|
|
if e.Summary {
|
|
for i, c := range calls {
|
|
compiledTask, err := e.FastCompiledTask(c)
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
summary.PrintSpaceBetweenSummaries(e.Logger, i)
|
|
summary.PrintTask(e.Logger, compiledTask)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Prompt for all required vars from deps upfront (parallel execution)
|
|
if err := e.promptDepsVars(calls); err != nil {
|
|
return err
|
|
}
|
|
|
|
regularCalls, watchCalls, err := e.splitRegularAndWatchCalls(calls...)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
g := &errgroup.Group{}
|
|
if e.Failfast {
|
|
g, ctx = errgroup.WithContext(ctx)
|
|
}
|
|
for _, c := range regularCalls {
|
|
if e.Parallel {
|
|
g.Go(func() error { return e.RunTask(ctx, c) })
|
|
} else {
|
|
if err := e.RunTask(ctx, c); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
}
|
|
if err := g.Wait(); err != nil {
|
|
return err
|
|
}
|
|
|
|
if len(watchCalls) > 0 {
|
|
return e.watchTasks(watchCalls...)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (e *Executor) splitRegularAndWatchCalls(calls ...*Call) (regularCalls []*Call, watchCalls []*Call, err error) {
|
|
for _, c := range calls {
|
|
t, err := e.GetTask(c)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
|
|
if e.Watch || t.Watch {
|
|
watchCalls = append(watchCalls, c)
|
|
} else {
|
|
regularCalls = append(regularCalls, c)
|
|
}
|
|
}
|
|
return regularCalls, watchCalls, err
|
|
}
|
|
|
|
// RunTask runs a task by its name
|
|
func (e *Executor) RunTask(ctx context.Context, call *Call) error {
|
|
// Inject prompted vars into call if available
|
|
if e.promptedVars != nil {
|
|
if call.Vars == nil {
|
|
call.Vars = ast.NewVars()
|
|
}
|
|
for name, v := range e.promptedVars.All() {
|
|
// Only inject if not already set in call
|
|
if _, ok := call.Vars.Get(name); !ok {
|
|
call.Vars.Set(name, v)
|
|
}
|
|
}
|
|
}
|
|
|
|
t, err := e.FastCompiledTask(call)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if !shouldRunOnCurrentPlatform(t.Platforms) {
|
|
e.Logger.VerboseOutf(logger.Yellow, `task: %q not for current platform - ignored\n`, call.Task)
|
|
return nil
|
|
}
|
|
|
|
// Check required vars early (before template compilation) if we can't prompt.
|
|
// This gives a clear "missing required variables" error instead of a template error.
|
|
if !e.canPrompt() {
|
|
if err := e.areTaskRequiredVarsSet(t); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
t, err = e.CompiledTask(call)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// Check if condition after CompiledTask so dynamic variables are resolved
|
|
if strings.TrimSpace(t.If) != "" {
|
|
if err := execext.RunCommand(ctx, &execext.RunCommandOptions{
|
|
Command: t.If,
|
|
Dir: t.Dir,
|
|
Env: env.Get(t),
|
|
}); err != nil {
|
|
e.Logger.VerboseOutf(logger.Yellow, "task: if condition not met - skipped: %q\n", call.Task)
|
|
return nil
|
|
}
|
|
}
|
|
|
|
// Prompt for missing required vars after if check (avoid prompting if task won't run)
|
|
prompted, err := e.promptTaskVars(t, call)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if prompted {
|
|
// Recompile with the new vars
|
|
t, err = e.FastCompiledTask(call)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
if err := e.areTaskRequiredVarsSet(t); err != nil {
|
|
return err
|
|
}
|
|
|
|
if err := e.areTaskRequiredVarsAllowedValuesSet(t); err != nil {
|
|
return err
|
|
}
|
|
|
|
if !e.Watch && atomic.AddInt32(e.taskCallCount[t.Task], 1) >= MaximumTaskCall {
|
|
return &errors.TaskCalledTooManyTimesError{
|
|
TaskName: t.Task,
|
|
MaximumTaskCall: MaximumTaskCall,
|
|
}
|
|
}
|
|
|
|
release := e.acquireConcurrencyLimit()
|
|
defer release()
|
|
|
|
if err = e.startExecution(ctx, t, func(ctx context.Context) error {
|
|
e.Logger.VerboseErrf(logger.Magenta, "task: %q started\n", call.Task)
|
|
if err := e.runDeps(ctx, t); err != nil {
|
|
return err
|
|
}
|
|
|
|
skipFingerprinting := e.ForceAll || (!call.Indirect && e.Force)
|
|
if !skipFingerprinting {
|
|
if err := ctx.Err(); err != nil {
|
|
return err
|
|
}
|
|
|
|
preCondMet, err := e.areTaskPreconditionsMet(ctx, t)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// Get the fingerprinting method to use
|
|
method := e.Taskfile.Method
|
|
if t.Method != "" {
|
|
method = t.Method
|
|
}
|
|
upToDate, err := fingerprint.IsTaskUpToDate(ctx, t,
|
|
fingerprint.WithMethod(method),
|
|
fingerprint.WithTempDir(e.TempDir.Fingerprint),
|
|
fingerprint.WithDry(e.Dry),
|
|
fingerprint.WithLogger(e.Logger),
|
|
)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if upToDate && preCondMet {
|
|
if e.Verbose || (!call.Silent && !t.IsSilent() && !e.Taskfile.Silent && !e.Silent) {
|
|
name := t.Name()
|
|
if e.OutputStyle.Name == "prefixed" {
|
|
name = t.Prefix
|
|
}
|
|
e.Logger.Errf(logger.Magenta, "task: Task %q is up to date\n", name)
|
|
}
|
|
return nil
|
|
}
|
|
}
|
|
|
|
for _, p := range t.Prompt {
|
|
if p != "" && !e.Dry {
|
|
if err := e.Logger.Prompt(logger.Yellow, p, "n", "y", "yes"); errors.Is(err, logger.ErrNoTerminal) {
|
|
return &errors.TaskCancelledNoTerminalError{TaskName: call.Task}
|
|
} else if errors.Is(err, logger.ErrPromptCancelled) {
|
|
return &errors.TaskCancelledByUserError{TaskName: call.Task}
|
|
} else if err != nil {
|
|
return err
|
|
}
|
|
}
|
|
}
|
|
|
|
if err := e.mkdir(t); err != nil {
|
|
e.Logger.Errf(logger.Red, "task: cannot make directory %q: %v\n", t.Dir, err)
|
|
}
|
|
|
|
var deferredExitCode uint8
|
|
|
|
for i := range t.Cmds {
|
|
if t.Cmds[i].Defer {
|
|
defer e.runDeferred(t, call, i, t.Vars, &deferredExitCode)
|
|
continue
|
|
}
|
|
|
|
if err := e.runCommand(ctx, t, call, i); err != nil {
|
|
if err2 := e.statusOnError(t); err2 != nil {
|
|
e.Logger.VerboseErrf(logger.Yellow, "task: error cleaning status on error: %v\n", err2)
|
|
}
|
|
|
|
var exitCode interp.ExitStatus
|
|
if errors.As(err, &exitCode) {
|
|
if t.IgnoreError {
|
|
e.Logger.VerboseErrf(logger.Yellow, "task: task error ignored: %v\n", err)
|
|
continue
|
|
}
|
|
deferredExitCode = uint8(exitCode)
|
|
}
|
|
|
|
return err
|
|
}
|
|
}
|
|
e.Logger.VerboseErrf(logger.Magenta, "task: %q finished\n", call.Task)
|
|
return nil
|
|
}); err != nil {
|
|
return &errors.TaskRunError{TaskName: t.Name(), Err: err}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (e *Executor) mkdir(t *ast.Task) error {
|
|
if t.Dir == "" {
|
|
return nil
|
|
}
|
|
|
|
mutex := e.mkdirMutexMap[t.Task]
|
|
mutex.Lock()
|
|
defer mutex.Unlock()
|
|
|
|
if _, err := os.Stat(t.Dir); os.IsNotExist(err) {
|
|
if err := os.MkdirAll(t.Dir, 0o755); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (e *Executor) runDeps(ctx context.Context, t *ast.Task) error {
|
|
g := &errgroup.Group{}
|
|
if e.Failfast || t.Failfast {
|
|
g, ctx = errgroup.WithContext(ctx)
|
|
}
|
|
|
|
reacquire := e.releaseConcurrencyLimit()
|
|
defer reacquire()
|
|
|
|
for _, d := range t.Deps {
|
|
g.Go(func() error {
|
|
err := e.RunTask(ctx, &Call{Task: d.Task, Vars: d.Vars, Silent: d.Silent, Indirect: true})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return nil
|
|
})
|
|
}
|
|
|
|
return g.Wait()
|
|
}
|
|
|
|
func (e *Executor) runDeferred(t *ast.Task, call *Call, i int, vars *ast.Vars, deferredExitCode *uint8) {
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
defer cancel()
|
|
|
|
cmd := t.Cmds[i]
|
|
cache := &templater.Cache{Vars: vars}
|
|
extra := map[string]any{}
|
|
|
|
if deferredExitCode != nil && *deferredExitCode > 0 {
|
|
extra["EXIT_CODE"] = fmt.Sprintf("%d", *deferredExitCode)
|
|
}
|
|
|
|
cmd.Cmd = templater.ReplaceWithExtra(cmd.Cmd, cache, extra)
|
|
cmd.Task = templater.ReplaceWithExtra(cmd.Task, cache, extra)
|
|
cmd.If = templater.ReplaceWithExtra(cmd.If, cache, extra)
|
|
cmd.Vars = templater.ReplaceVarsWithExtra(cmd.Vars, cache, extra)
|
|
|
|
if err := e.runCommand(ctx, t, call, i); err != nil {
|
|
e.Logger.VerboseErrf(logger.Yellow, "task: ignored error in deferred cmd: %s\n", err.Error())
|
|
}
|
|
}
|
|
|
|
func (e *Executor) runCommand(ctx context.Context, t *ast.Task, call *Call, i int) error {
|
|
cmd := t.Cmds[i]
|
|
|
|
// Check if condition for any command type
|
|
if strings.TrimSpace(cmd.If) != "" {
|
|
if err := execext.RunCommand(ctx, &execext.RunCommandOptions{
|
|
Command: cmd.If,
|
|
Dir: t.Dir,
|
|
Env: env.Get(t),
|
|
}); err != nil {
|
|
e.Logger.VerboseOutf(logger.Yellow, "task: [%s] if condition not met - skipped\n", t.Name())
|
|
return nil
|
|
}
|
|
}
|
|
|
|
switch {
|
|
case cmd.Task != "":
|
|
reacquire := e.releaseConcurrencyLimit()
|
|
defer reacquire()
|
|
|
|
err := e.RunTask(ctx, &Call{Task: cmd.Task, Vars: cmd.Vars, Silent: cmd.Silent, Indirect: true})
|
|
var exitCode interp.ExitStatus
|
|
if errors.As(err, &exitCode) && cmd.IgnoreError {
|
|
e.Logger.VerboseErrf(logger.Yellow, "task: [%s] task error ignored: %v\n", t.Name(), err)
|
|
return nil
|
|
}
|
|
return err
|
|
case cmd.Cmd != "":
|
|
if !shouldRunOnCurrentPlatform(cmd.Platforms) {
|
|
e.Logger.VerboseOutf(logger.Yellow, "task: [%s] %s not for current platform - ignored\n", t.Name(), cmd.Cmd)
|
|
return nil
|
|
}
|
|
|
|
if e.Verbose || (!call.Silent && !cmd.Silent && !t.IsSilent() && !e.Taskfile.Silent && !e.Silent) {
|
|
e.Logger.Errf(logger.Green, "task: [%s] %s\n", t.Name(), cmd.Cmd)
|
|
}
|
|
|
|
if e.Dry {
|
|
return nil
|
|
}
|
|
|
|
outputWrapper := e.Output
|
|
if t.Interactive {
|
|
outputWrapper = output.Interleaved{}
|
|
}
|
|
vars, err := e.Compiler.FastGetVariables(t, call)
|
|
outputTemplater := &templater.Cache{Vars: vars}
|
|
if err != nil {
|
|
return fmt.Errorf("task: failed to get variables: %w", err)
|
|
}
|
|
stdOut, stdErr, closer := outputWrapper.WrapWriter(e.Stdout, e.Stderr, t.Prefix, outputTemplater)
|
|
|
|
err = execext.RunCommand(ctx, &execext.RunCommandOptions{
|
|
Command: cmd.Cmd,
|
|
Dir: t.Dir,
|
|
Env: env.Get(t),
|
|
PosixOpts: slicesext.UniqueJoin(e.Taskfile.Set, t.Set, cmd.Set),
|
|
BashOpts: slicesext.UniqueJoin(e.Taskfile.Shopt, t.Shopt, cmd.Shopt),
|
|
Stdin: e.Stdin,
|
|
Stdout: stdOut,
|
|
Stderr: stdErr,
|
|
})
|
|
if closeErr := closer(err); closeErr != nil {
|
|
e.Logger.Errf(logger.Red, "task: unable to close writer: %v\n", closeErr)
|
|
}
|
|
var exitCode interp.ExitStatus
|
|
if errors.As(err, &exitCode) && cmd.IgnoreError {
|
|
e.Logger.VerboseErrf(logger.Yellow, "task: [%s] command error ignored: %v\n", t.Name(), err)
|
|
return nil
|
|
}
|
|
return err
|
|
default:
|
|
return nil
|
|
}
|
|
}
|
|
|
|
func (e *Executor) startExecution(ctx context.Context, t *ast.Task, execute func(ctx context.Context) error) error {
|
|
h, err := e.GetHash(t)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if h == "" || t.Watch {
|
|
return execute(ctx)
|
|
}
|
|
|
|
e.executionHashesMutex.Lock()
|
|
|
|
if otherExecutionCtx, ok := e.executionHashes[h]; ok {
|
|
e.executionHashesMutex.Unlock()
|
|
e.Logger.VerboseErrf(logger.Magenta, "task: skipping execution of task: %s\n", h)
|
|
|
|
// Release our execution slot to avoid blocking other tasks while we wait
|
|
reacquire := e.releaseConcurrencyLimit()
|
|
defer reacquire()
|
|
|
|
<-otherExecutionCtx.Done()
|
|
return nil
|
|
}
|
|
|
|
ctx, cancel := context.WithCancel(ctx)
|
|
defer cancel()
|
|
|
|
e.executionHashes[h] = ctx
|
|
e.executionHashesMutex.Unlock()
|
|
|
|
return execute(ctx)
|
|
}
|
|
|
|
// FindMatchingTasks returns a list of tasks that match the given call. A task
|
|
// matches a call if its name is equal to the call's task name, or one of aliases, or if it matches
|
|
// a wildcard pattern. The function returns a list of MatchingTask structs, each
|
|
// containing a task and a list of wildcards that were matched.
|
|
// If multiple tasks match due to aliases, a TaskNameConflictError is returned.
|
|
func (e *Executor) FindMatchingTasks(call *Call) ([]*MatchingTask, error) {
|
|
if call == nil {
|
|
return nil, nil
|
|
}
|
|
var matchingTasks []*MatchingTask
|
|
// If there is a direct match, return it
|
|
if task, ok := e.Taskfile.Tasks.Get(call.Task); ok {
|
|
matchingTasks = append(matchingTasks, &MatchingTask{Task: task, Wildcards: nil})
|
|
return matchingTasks, nil
|
|
}
|
|
var aliasedTasks []string
|
|
for task := range e.Taskfile.Tasks.Values(nil) {
|
|
if slices.Contains(task.Aliases, call.Task) {
|
|
aliasedTasks = append(aliasedTasks, task.Task)
|
|
matchingTasks = append(matchingTasks, &MatchingTask{Task: task, Wildcards: nil})
|
|
}
|
|
}
|
|
|
|
if len(aliasedTasks) == 1 {
|
|
return matchingTasks, nil
|
|
}
|
|
|
|
// If we found multiple tasks
|
|
if len(aliasedTasks) > 1 {
|
|
return nil, &errors.TaskNameConflictError{
|
|
Call: call.Task,
|
|
TaskNames: aliasedTasks,
|
|
}
|
|
}
|
|
|
|
// Attempt a wildcard match
|
|
for _, value := range e.Taskfile.Tasks.All(nil) {
|
|
if match, wildcards := value.WildcardMatch(call.Task); match {
|
|
matchingTasks = append(matchingTasks, &MatchingTask{
|
|
Task: value,
|
|
Wildcards: wildcards,
|
|
})
|
|
}
|
|
}
|
|
return matchingTasks, nil
|
|
}
|
|
|
|
// GetTask will return the task with the name matching the given call from the taskfile.
|
|
// If no task is found, it will search for tasks with a matching alias.
|
|
// If multiple tasks contain the same alias or no matches are found an error is returned.
|
|
func (e *Executor) GetTask(call *Call) (*ast.Task, error) {
|
|
// Search for a matching task
|
|
matchingTasks, err := e.FindMatchingTasks(call)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if len(matchingTasks) > 0 {
|
|
if call.Vars == nil {
|
|
call.Vars = ast.NewVars()
|
|
}
|
|
call.Vars.Set("MATCH", ast.Var{Value: matchingTasks[0].Wildcards})
|
|
return matchingTasks[0].Task, nil
|
|
}
|
|
|
|
// If we found no tasks
|
|
didYouMean := ""
|
|
if !e.DisableFuzzy {
|
|
e.fuzzyModelOnce.Do(e.setupFuzzyModel)
|
|
if e.fuzzyModel != nil {
|
|
didYouMean = e.fuzzyModel.SpellCheck(call.Task)
|
|
}
|
|
}
|
|
return nil, &errors.TaskNotFoundError{
|
|
TaskName: call.Task,
|
|
DidYouMean: didYouMean,
|
|
}
|
|
}
|
|
|
|
type FilterFunc func(task *ast.Task) bool
|
|
|
|
func (e *Executor) GetTaskList(filters ...FilterFunc) ([]*ast.Task, error) {
|
|
tasks := make([]*ast.Task, 0, e.Taskfile.Tasks.Len())
|
|
|
|
// Create an error group to wait for each task to be compiled
|
|
var g errgroup.Group
|
|
|
|
// Sort the tasks
|
|
if e.TaskSorter == nil {
|
|
e.TaskSorter = sort.AlphaNumericWithRootTasksFirst
|
|
}
|
|
|
|
// Filter tasks based on the given filter functions
|
|
for task := range e.Taskfile.Tasks.Values(e.TaskSorter) {
|
|
var shouldFilter bool
|
|
for _, filter := range filters {
|
|
if filter(task) {
|
|
shouldFilter = true
|
|
}
|
|
}
|
|
if !shouldFilter {
|
|
tasks = append(tasks, task)
|
|
}
|
|
}
|
|
|
|
// Compile the list of tasks
|
|
for i := range tasks {
|
|
g.Go(func() error {
|
|
compiledTask, err := e.CompiledTaskForTaskList(&Call{Task: tasks[i].Task})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
tasks[i] = compiledTask
|
|
return nil
|
|
})
|
|
}
|
|
|
|
// Wait for all the go routines to finish
|
|
if err := g.Wait(); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return tasks, nil
|
|
}
|
|
|
|
// FilterOutNoDesc removes all tasks that do not contain a description.
|
|
func FilterOutNoDesc(task *ast.Task) bool {
|
|
return task.Desc == ""
|
|
}
|
|
|
|
// FilterOutInternal removes all tasks that are marked as internal.
|
|
func FilterOutInternal(task *ast.Task) bool {
|
|
return task.Internal
|
|
}
|
|
|
|
func shouldRunOnCurrentPlatform(platforms []*ast.Platform) bool {
|
|
if len(platforms) == 0 {
|
|
return true
|
|
}
|
|
for _, p := range platforms {
|
|
if (p.OS == "" || p.OS == runtime.GOOS) && (p.Arch == "" || p.Arch == runtime.GOARCH) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|