Skip to content
Open
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
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,11 @@

## Unreleased

- Added opt-in task-level grouping for `output: group`. Set
`by_task: true` or use `--output-group-by-task` to combine all shell command
output from a task into one group while preserving the existing per-command
default.

- **Remote Taskfiles are now generally available!** This has been an
experimental feature for 3 years, but is now enabled by default. Massive
thanks to all those that contributed and gave feedback (too many to list
Expand Down
1 change: 1 addition & 0 deletions completion/fish/task.fish
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,7 @@ complete -c $GO_TASK_PROGNAME -s o -l output -d 'set output s
complete -c $GO_TASK_PROGNAME -l output-group-begin -d 'message template before grouped output'
complete -c $GO_TASK_PROGNAME -l output-group-end -d 'message template after grouped output'
complete -c $GO_TASK_PROGNAME -l output-group-error-only -d 'hide output from successful tasks'
complete -c $GO_TASK_PROGNAME -l output-group-by-task -d 'group all task output together'
complete -c $GO_TASK_PROGNAME -s p -l parallel -d 'execute tasks in parallel'
complete -c $GO_TASK_PROGNAME -s s -l silent -d 'disable echoing'
complete -c $GO_TASK_PROGNAME -l sort -d 'set task sorting order' -xa "default alphanumeric none"
Expand Down
1 change: 1 addition & 0 deletions completion/nu/task-completions.nu
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,7 @@ export extern "task" [
--output-group-begin: string # message template printed before a task's grouped output
--output-group-end: string # message template printed after a task's grouped output
--output-group-error-only # swallow the output of successful tasks
--output-group-by-task # group all task output together
--color(-c) # colored output, enabled by default
--silent(-s) # disable echoing
--verbose(-v) # enable verbose mode
Expand Down
1 change: 1 addition & 0 deletions completion/ps/task.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ Register-ArgumentCompleter -CommandName $cmdNames -ScriptBlock {
[CompletionResult]::new('--output-group-begin', '--output-group-begin', [CompletionResultType]::ParameterName, 'template before group'),
[CompletionResult]::new('--output-group-end', '--output-group-end', [CompletionResultType]::ParameterName, 'template after group'),
[CompletionResult]::new('--output-group-error-only', '--output-group-error-only', [CompletionResultType]::ParameterName, 'hide successful output'),
[CompletionResult]::new('--output-group-by-task', '--output-group-by-task', [CompletionResultType]::ParameterName, 'group all task output together'),
[CompletionResult]::new('-p', '-p', [CompletionResultType]::ParameterName, 'execute in parallel'),
[CompletionResult]::new('--parallel', '--parallel', [CompletionResultType]::ParameterName, 'execute in parallel'),
[CompletionResult]::new('-s', '-s', [CompletionResultType]::ParameterName, 'silent mode'),
Expand Down
1 change: 1 addition & 0 deletions completion/zsh/_task
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,7 @@ _task() {
'(--output-group-begin)--output-group-begin[message template before grouped output]:template text: '
'(--output-group-end)--output-group-end[message template after grouped output]:template text: '
'(--output-group-error-only)--output-group-error-only[hide output from successful tasks]'
'(--output-group-by-task)--output-group-by-task[group all task output together]'
'(-s --silent)'{-s,--silent}'[disable echoing]'
'(--sort)--sort[set task sorting order]:order:(default alphanumeric none)'
'(--status)--status[exit non-zero if supplied tasks not up-to-date]'
Expand Down
11 changes: 11 additions & 0 deletions internal/flags/flags.go
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,7 @@ func init() {
pflag.StringVar(&Output.Group.Begin, "output-group-begin", getConfig(config, "OUTPUT_GROUP_BEGIN", func() *string { return nil }, ""), "Message template to print before a task's grouped output.")
pflag.StringVar(&Output.Group.End, "output-group-end", getConfig(config, "OUTPUT_GROUP_END", func() *string { return nil }, ""), "Message template to print after a task's grouped output.")
pflag.BoolVar(&Output.Group.ErrorOnly, "output-group-error-only", getConfig(config, "OUTPUT_GROUP_ERROR_ONLY", func() *bool { return nil }, false), "Swallow output from successful tasks.")
pflag.BoolVar(&Output.Group.ByTask, "output-group-by-task", getOutputGroupByTask(), "Group output by task instead of command.")
pflag.BoolVarP(&Color, "color", "c", getConfig(config, "COLOR", func() *bool { return config.Color }, true), "Colored output. Enabled by default. Set flag to false or use NO_COLOR=1 to disable.")
pflag.IntVarP(&Concurrency, "concurrency", "C", getConfig(config, "CONCURRENCY", func() *int { return config.Concurrency }, 0), "Limit number of tasks to run concurrently.")
pflag.DurationVarP(&Interval, "interval", "I", 0, "Interval to watch for changes.")
Expand Down Expand Up @@ -223,6 +224,9 @@ func Validate() error {
if Output.Group.ErrorOnly {
return errors.New("task: You can't set --output-group-error-only without --output=group")
}
if Output.Group.ByTask {
return errors.New("task: You can't set --output-group-by-task without --output=group")
}
}

if List && ListAll {
Expand Down Expand Up @@ -311,6 +315,13 @@ func (o *flagsOption) ApplyToExecutor(e *task.Executor) {
)
}

func getOutputGroupByTask() bool {
if value, ok := getEnvAs[bool]("OUTPUT_GROUP_BY_TASK"); ok {
return value
}
return false
}

// getConfig extracts a config value with priority: env var > taskrc config > fallback
func getConfig[T any](config *taskrcast.TaskRC, envKey string, fieldFunc func() *T, fallback T) T {
if envKey != "" {
Expand Down
104 changes: 86 additions & 18 deletions task.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package task
import (
"context"
"fmt"
"io"
"os"
"runtime"
"slices"
Expand Down Expand Up @@ -203,7 +204,8 @@ func (e *Executor) RunTask(ctx context.Context, call *Call) error {
release := e.acquireConcurrencyLimit()
defer release()

if err = e.startExecution(ctx, t, func(ctx context.Context) error {
var taskOut *taskOutput
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
Expand Down Expand Up @@ -253,15 +255,22 @@ func (e *Executor) RunTask(ctx context.Context, call *Call) error {
e.Logger.Errf(logger.Red, "task: cannot make directory %q: %v\n", t.Dir, err)
}

if e.OutputStyle.Name == "group" && e.OutputStyle.Group.ByTask && !t.Interactive && !e.Dry {
taskOut, err = e.newTaskOutput(t, call)
if err != nil {
return err
}
}

var deferredExitCode uint8

for i := range t.Cmds {
if t.Cmds[i].Defer {
defer e.runDeferred(t, call, i, t.Vars, &deferredExitCode)
defer e.runDeferred(t, call, i, t.Vars, &deferredExitCode, taskOut)
continue
}

if err := e.runCommand(ctx, t, call, i); err != nil {
if err := e.runCommand(ctx, t, call, i, taskOut); err != nil {
if err2 := e.statusOnError(t); err2 != nil {
e.Logger.VerboseErrf(logger.Yellow, "task: error cleaning status on error: %v\n", err2)
}
Expand All @@ -285,9 +294,21 @@ func (e *Executor) RunTask(ctx context.Context, call *Call) error {
return err
}
}

e.Logger.VerboseErrf(logger.Magenta, "task: %q finished\n", call.Task)
return nil
}); err != nil {
})

if taskOut != nil {
if closeErr := taskOut.close(err); closeErr != nil {
e.Logger.Errf(logger.Red, "task: unable to close writer: %v\n", closeErr)
if err == nil {
err = closeErr
}
}
}

if err != nil {
return &errors.TaskRunError{TaskName: t.Name(), Err: err}
}

Expand Down Expand Up @@ -342,7 +363,39 @@ func (e *Executor) runDeps(ctx context.Context, t *ast.Task) error {
return g.Wait()
}

func (e *Executor) runDeferred(t *ast.Task, call *Call, i int, vars *ast.Vars, deferredExitCode *uint8) {
type taskOutput struct {
stdOut, stdErr io.Writer
closer output.CloseFunc
failed bool
}

func (e *Executor) newTaskOutput(t *ast.Task, call *Call) (*taskOutput, error) {
vars, err := e.Compiler.FastGetVariables(t, call)
if err != nil {
return nil, fmt.Errorf("task: failed to get variables: %w", err)
}

stdOut, stdErr, closer := e.Output.WrapWriter(
e.Stdout,
e.Stderr,
t.Prefix,
&templater.Cache{Vars: vars},
)
return &taskOutput{
stdOut: stdOut,
stdErr: stdErr,
closer: closer,
}, nil
}

func (o *taskOutput) close(err error) error {
if err == nil && o.failed {
err = fmt.Errorf("one or more task commands failed")
}
return o.closer(err)
}

func (e *Executor) runDeferred(t *ast.Task, call *Call, i int, vars *ast.Vars, deferredExitCode *uint8, taskOut *taskOutput) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()

Expand All @@ -361,12 +414,12 @@ func (e *Executor) runDeferred(t *ast.Task, call *Call, i int, vars *ast.Vars, d
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 {
if err := e.runCommand(ctx, t, call, i, taskOut); 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 {
func (e *Executor) runCommand(ctx context.Context, t *ast.Task, call *Call, i int, taskOut *taskOutput) error {
cmd := t.Cmds[i]

// In place before the if condition, which would otherwise run unbounded.
Expand Down Expand Up @@ -406,6 +459,9 @@ func (e *Executor) runCommand(ctx context.Context, t *ast.Task, call *Call, i in
e.Logger.VerboseErrf(logger.Yellow, "task: [%s] task error ignored: %v\n", t.Name(), err)
return nil
}
if taskOut != nil && err != nil {
taskOut.failed = true
}
return err
case cmd.Cmd != "":
if !shouldRunOnCurrentPlatform(cmd.Platforms) {
Expand All @@ -421,16 +477,23 @@ func (e *Executor) runCommand(ctx context.Context, t *ast.Task, call *Call, i in
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)
var stdOut, stdErr io.Writer
var closer output.CloseFunc
var err error
if taskOut != nil {
stdOut, stdErr = taskOut.stdOut, taskOut.stdErr
} else {
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)
}
stdOut, stdErr, closer := outputWrapper.WrapWriter(e.Stdout, e.Stderr, t.Prefix, outputTemplater)

err = execext.RunCommand(ctx, &execext.RunCommandOptions{
Command: cmd.Cmd,
Expand All @@ -442,8 +505,10 @@ func (e *Executor) runCommand(ctx context.Context, t *ast.Task, call *Call, i in
Stdout: stdOut,
Stderr: stdErr,
})
if closeErr := closer(err); closeErr != nil {
e.Logger.Errf(logger.Red, "task: unable to close writer: %v\n", closeErr)
if taskOut == nil {
if closeErr := closer(err); closeErr != nil {
e.Logger.Errf(logger.Red, "task: unable to close writer: %v\n", closeErr)
}
}
if err != nil && timedOut(ctx, timeout) {
err = timeout
Expand All @@ -452,6 +517,9 @@ func (e *Executor) runCommand(ctx context.Context, t *ast.Task, call *Call, i in
e.Logger.VerboseErrf(logger.Yellow, "task: [%s] command error ignored: %v\n", t.Name(), err)
return nil
}
if taskOut != nil && err != nil {
taskOut.failed = true
}
return err
default:
return nil
Expand Down
118 changes: 118 additions & 0 deletions task_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2610,6 +2610,124 @@ Bye!
assert.Equal(t, strings.TrimSpace(buff.String()), expectedOutputOrder)
}

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

const dir = "testdata/output_group"
var buff bytes.Buffer
e := task.NewExecutor(
task.WithDir(dir),
task.WithStdout(&buff),
task.WithStderr(&buff),
task.WithSilent(true),
)
require.NoError(t, e.Setup())

require.NoError(t, e.Run(t.Context(), &task.Call{Task: "multi"}))
expectedOutput := strings.TrimSpace(`
::group::multi
first
::endgroup::
::group::multi
second
::endgroup::
`)
assert.Equal(t, expectedOutput, strings.TrimSpace(buff.String()))
}

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

const dir = "testdata/output_group_by_task"
var buff bytes.Buffer
e := task.NewExecutor(
task.WithDir(dir),
task.WithStdout(&buff),
task.WithStderr(&buff),
)
require.NoError(t, e.Setup())

require.NoError(t, e.Run(t.Context(), &task.Call{Task: "multi"}))
expectedMulti := strings.TrimSpace(`
::group::multi
first
second
::endgroup::
`)
assert.Equal(t, expectedMulti, strings.TrimSpace(buff.String()))

buff.Reset()
require.NoError(t, e.Run(t.Context(), &task.Call{Task: "deferred"}))
expectedDeferred := strings.TrimSpace(`
::group::deferred
first
second
deferred
::endgroup::
`)
assert.Equal(t, expectedDeferred, strings.TrimSpace(buff.String()))

buff.Reset()
require.NoError(t, e.Run(t.Context(), &task.Call{Task: "parent"}))
expectedParent := strings.TrimSpace(`
::group::child
child
::endgroup::
::group::parent
parent-before
parent-after
::endgroup::
`)
assert.Equal(t, expectedParent, strings.TrimSpace(buff.String()))

var parallelBuff SyncBuffer
parallelExecutor := task.NewExecutor(
task.WithDir(dir),
task.WithStdout(&parallelBuff),
task.WithStderr(&parallelBuff),
)
require.NoError(t, parallelExecutor.Setup())
require.NoError(t, parallelExecutor.Run(t.Context(), &task.Call{Task: "parallel"}))

parallelOutput := parallelBuff.buf.String()
blocks := strings.Split(strings.TrimSpace(parallelOutput), "::group::")
require.Len(t, blocks, 3)
for _, block := range blocks[1:] {
assert.Equal(t, 1, strings.Count(block, "::endgroup::"))
assert.False(t, strings.Contains(block, "dep-one-first") && strings.Contains(block, "dep-two-first"))
}
assert.Contains(t, parallelOutput, "dep-one-first\ndep-one-second")
assert.Contains(t, parallelOutput, "dep-two-first\ndep-two-second")
}

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

const dir = "testdata/output_group_by_task_error_only"
var buff bytes.Buffer
e := task.NewExecutor(
task.WithDir(dir),
task.WithStdout(&buff),
task.WithStderr(&buff),
)
require.NoError(t, e.Setup())

require.NoError(t, e.Run(t.Context(), &task.Call{Task: "passing"}))
assert.Empty(t, buff.String())

buff.Reset()
require.Error(t, e.Run(t.Context(), &task.Call{Task: "failing"}))
assert.Equal(t, "failing-first\nfailing-second\n", buff.String())

buff.Reset()
require.NoError(t, e.Run(t.Context(), &task.Call{Task: "ignored-command"}))
assert.Empty(t, buff.String())

buff.Reset()
require.NoError(t, e.Run(t.Context(), &task.Call{Task: "ignored-task"}))
assert.Equal(t, "child-failing-first\nchild-failing-second\n", buff.String())
}

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

Expand Down
1 change: 1 addition & 0 deletions taskfile/ast/output.go
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ func (s *Output) UnmarshalYAML(node *yaml.Node) error {
type OutputGroup struct {
Begin, End string
ErrorOnly bool `yaml:"error_only"`
ByTask bool `yaml:"by_task"`
}

// IsSet returns true if and only if a custom output style is set.
Expand Down
Loading