diff --git a/README.md b/README.md index 502cd7a2..9595e4a5 100644 --- a/README.md +++ b/README.md @@ -632,6 +632,68 @@ tasks: - echo "This will print nothing" > /dev/null ``` +## Output syntax + +By default, Task just redirect the STDOUT and STDERR of the running commands +to the shell in real time. This is good for having live feedback for log +printed by commands, but the output can become messy if you have multiple +commands running at the same time and printing lots of stuff. + +To make this more customizable, there are currently three different output +options you can choose: + +- `interleaved` (default) +- `group` +- `prefixed` + + To choose another one, just set it to root in the Taskfile: + + ```yml +version: '2' + +output: 'group' + +tasks: + # ... + ``` + + The `group` output will print the entire output of a command once, after it + finishes, so you won't have live feedback for commands that take a long time + to run. + + The `prefix` output will prefix every line printed by a command with + `[task-name] ` as the prefix, but you can customize the prefix for a command + with the `prefix:` attribute: + + ```yml +version: '2' + +output: prefixed + +tasks: + default: + deps: + - task: print + vars: {TEXT: foo} + - task: print + vars: {TEXT: bar} + - task: print + vars: {TEXT: baz} + + print: + cmds: + - echo "{{.TEXT}}" + prefix: "print-{{.TEXT}}" + silent: true +``` + +```bash +$ task default +[print-foo] foo +[print-bar] bar +[print-baz] baz +``` + ## Watch tasks If you give a `--watch` or `-w` argument, task will watch for file changes diff --git a/Taskfile.yml b/Taskfile.yml index 8c2a2a7b..f92f726d 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -13,6 +13,7 @@ vars: ./internal/compiler/v2 ./internal/execext ./internal/logger + ./internal/output ./internal/status ./internal/taskfile ./internal/taskfile/version diff --git a/internal/output/group.go b/internal/output/group.go new file mode 100644 index 00000000..62259f34 --- /dev/null +++ b/internal/output/group.go @@ -0,0 +1,26 @@ +package output + +import ( + "bytes" + "io" +) + +type Group struct{} + +func (Group) WrapWriter(w io.Writer, _ string) io.WriteCloser { + return &groupWriter{writer: w} +} + +type groupWriter struct { + writer io.Writer + buff bytes.Buffer +} + +func (gw *groupWriter) Write(p []byte) (int, error) { + return gw.buff.Write(p) +} + +func (gw *groupWriter) Close() error { + _, err := io.Copy(gw.writer, &gw.buff) + return err +} diff --git a/internal/output/interleaved.go b/internal/output/interleaved.go new file mode 100644 index 00000000..305338d2 --- /dev/null +++ b/internal/output/interleaved.go @@ -0,0 +1,23 @@ +package output + +import ( + "io" +) + +type Interleaved struct{} + +func (Interleaved) WrapWriter(w io.Writer, _ string) io.WriteCloser { + return nopWriterCloser{w: w} +} + +type nopWriterCloser struct { + w io.Writer +} + +func (wc nopWriterCloser) Write(p []byte) (int, error) { + return wc.w.Write(p) +} + +func (wc nopWriterCloser) Close() error { + return nil +} diff --git a/internal/output/output.go b/internal/output/output.go new file mode 100644 index 00000000..f63b83a7 --- /dev/null +++ b/internal/output/output.go @@ -0,0 +1,9 @@ +package output + +import ( + "io" +) + +type Output interface { + WrapWriter(w io.Writer, prefix string) io.WriteCloser +} diff --git a/internal/output/output_test.go b/internal/output/output_test.go new file mode 100644 index 00000000..e2efd2db --- /dev/null +++ b/internal/output/output_test.go @@ -0,0 +1,62 @@ +package output_test + +import ( + "bytes" + "fmt" + "testing" + + "github.com/go-task/task/internal/output" + + "github.com/stretchr/testify/assert" +) + +func TestInterleaved(t *testing.T) { + var b bytes.Buffer + var o output.Output = output.Interleaved{} + var w = o.WrapWriter(&b, "") + + fmt.Fprintln(w, "foo\nbar") + assert.Equal(t, "foo\nbar\n", b.String()) + fmt.Fprintln(w, "baz") + assert.Equal(t, "foo\nbar\nbaz\n", b.String()) +} + +func TestGroup(t *testing.T) { + var b bytes.Buffer + var o output.Output = output.Group{} + var w = o.WrapWriter(&b, "") + + fmt.Fprintln(w, "foo\nbar") + assert.Equal(t, "", b.String()) + fmt.Fprintln(w, "baz") + assert.Equal(t, "", b.String()) + assert.NoError(t, w.Close()) + assert.Equal(t, "foo\nbar\nbaz\n", b.String()) +} + +func TestPrefixed(t *testing.T) { + var b bytes.Buffer + var o output.Output = output.Prefixed{} + var w = o.WrapWriter(&b, "prefix") + + t.Run("simple use cases", func(t *testing.T) { + b.Reset() + + fmt.Fprintln(w, "foo\nbar") + assert.Equal(t, "[prefix] foo\n[prefix] bar\n", b.String()) + fmt.Fprintln(w, "baz") + assert.Equal(t, "[prefix] foo\n[prefix] bar\n[prefix] baz\n", b.String()) + }) + + t.Run("multiple writes for a single line", func(t *testing.T) { + b.Reset() + + for _, char := range []string{"T", "e", "s", "t", "!"} { + fmt.Fprint(w, char) + assert.Equal(t, "", b.String()) + } + + assert.NoError(t, w.Close()) + assert.Equal(t, "[prefix] Test!\n", b.String()) + }) +} diff --git a/internal/output/prefixed.go b/internal/output/prefixed.go new file mode 100644 index 00000000..f9ea8578 --- /dev/null +++ b/internal/output/prefixed.go @@ -0,0 +1,65 @@ +package output + +import ( + "bytes" + "fmt" + "io" + "strings" +) + +type Prefixed struct{} + +func (Prefixed) WrapWriter(w io.Writer, prefix string) io.WriteCloser { + return &prefixWriter{writer: w, prefix: prefix} +} + +type prefixWriter struct { + writer io.Writer + prefix string + buff bytes.Buffer +} + +func (pw *prefixWriter) Write(p []byte) (int, error) { + n, err := pw.buff.Write(p) + if err != nil { + return n, err + } + + return n, pw.writeOutputLines(false) +} + +func (pw *prefixWriter) Close() error { + return pw.writeOutputLines(true) +} + +func (pw *prefixWriter) writeOutputLines(force bool) error { + for { + line, err := pw.buff.ReadString('\n') + if err == nil { + if err = pw.writeLine(line); err != nil { + return err + } + } else if err == io.EOF { + // if this line was not a complete line, re-add to the buffer + if !force && !strings.HasSuffix(line, "\n") { + _, err = pw.buff.WriteString(line) + return err + } + + return pw.writeLine(line) + } else { + return err + } + } +} + +func (pw *prefixWriter) writeLine(line string) error { + if line == "" { + return nil + } + if !strings.HasSuffix(line, "\n") { + line += "\n" + } + _, err := fmt.Fprintf(pw.writer, "[%s] %s", pw.prefix, line) + return err +} diff --git a/internal/taskfile/task.go b/internal/taskfile/task.go index 6a2b4708..cf15ea8a 100644 --- a/internal/taskfile/task.go +++ b/internal/taskfile/task.go @@ -17,4 +17,5 @@ type Task struct { Env Vars Silent bool Method string + Prefix string } diff --git a/internal/taskfile/taskfile.go b/internal/taskfile/taskfile.go index b874c610..2ac0cf50 100644 --- a/internal/taskfile/taskfile.go +++ b/internal/taskfile/taskfile.go @@ -4,6 +4,7 @@ package taskfile type Taskfile struct { Version string Expansions int + Output string Vars Vars Tasks Tasks } @@ -18,6 +19,7 @@ func (tf *Taskfile) UnmarshalYAML(unmarshal func(interface{}) error) error { var taskfile struct { Version string Expansions int + Output string Vars Vars Tasks Tasks } @@ -26,6 +28,7 @@ func (tf *Taskfile) UnmarshalYAML(unmarshal func(interface{}) error) error { } tf.Version = taskfile.Version tf.Expansions = taskfile.Expansions + tf.Output = taskfile.Output tf.Vars = taskfile.Vars tf.Tasks = taskfile.Tasks if tf.Expansions <= 0 { diff --git a/internal/taskfile/version/version.go b/internal/taskfile/version/version.go index b0a6563b..2853c5c3 100644 --- a/internal/taskfile/version/version.go +++ b/internal/taskfile/version/version.go @@ -5,27 +5,30 @@ import ( ) var ( - v1 = mustVersion("1") - v2 = mustVersion("2") - - isV1 = mustConstraint("= 1") - isV2 = mustConstraint(">= 2") - isV21 = mustConstraint(">= 2.1") + v1 = mustVersion("1") + v2 = mustVersion("2") + v21 = mustVersion("2.1") + v22 = mustVersion("2.2") ) // IsV1 returns if is a given Taskfile version is version 1 -func IsV1(v *semver.Version) bool { - return isV1.Check(v) +func IsV1(v *semver.Constraints) bool { + return v.Check(v1) } // IsV2 returns if is a given Taskfile version is at least version 2 -func IsV2(v *semver.Version) bool { - return isV2.Check(v) +func IsV2(v *semver.Constraints) bool { + return v.Check(v2) } -// IsV21 returns if is a given Taskfile version is at least version 2 -func IsV21(v *semver.Version) bool { - return isV21.Check(v) +// IsV21 returns if is a given Taskfile version is at least version 2.1 +func IsV21(v *semver.Constraints) bool { + return v.Check(v21) +} + +// IsV22 returns if is a given Taskfile version is at least version 2.2 +func IsV22(v *semver.Constraints) bool { + return v.Check(v22) } func mustVersion(s string) *semver.Version { @@ -35,11 +38,3 @@ func mustVersion(s string) *semver.Version { } return v } - -func mustConstraint(s string) *semver.Constraints { - c, err := semver.NewConstraint(s) - if err != nil { - panic(err) - } - return c -} diff --git a/task.go b/task.go index cade5d35..e2de4c62 100644 --- a/task.go +++ b/task.go @@ -12,6 +12,7 @@ import ( compilerv2 "github.com/go-task/task/internal/compiler/v2" "github.com/go-task/task/internal/execext" "github.com/go-task/task/internal/logger" + "github.com/go-task/task/internal/output" "github.com/go-task/task/internal/taskfile" "github.com/go-task/task/internal/taskfile/version" @@ -44,6 +45,7 @@ type Executor struct { Logger *logger.Logger Compiler compiler.Compiler + Output output.Output taskvars taskfile.Vars @@ -79,7 +81,7 @@ func (e *Executor) Setup() error { return err } - v, err := semver.NewVersion(e.Taskfile.Version) + v, err := semver.NewConstraint(e.Taskfile.Version) if err != nil { return fmt.Errorf(`task: could not parse taskfile version "%s": %v`, e.Taskfile.Version, err) } @@ -108,7 +110,7 @@ func (e *Executor) Setup() error { Vars: e.taskvars, Logger: e.Logger, } - case version.IsV2(v): + case version.IsV2(v), version.IsV21(v): e.Compiler = &compilerv2.CompilerV2{ Dir: e.Dir, Taskvars: e.taskvars, @@ -116,8 +118,22 @@ func (e *Executor) Setup() error { Expansions: e.Taskfile.Expansions, Logger: e.Logger, } - case version.IsV21(v): - return fmt.Errorf(`task: Taskfile versions greater than v2 not implemented in the version of Task`) + case version.IsV22(v): + return fmt.Errorf(`task: Taskfile versions greater than v2.1 not implemented in the version of Task`) + } + + if !version.IsV21(v) && e.Taskfile.Output != "" { + return fmt.Errorf(`task: Taskfile option "output" is only available starting on Taskfile version v2.1`) + } + switch e.Taskfile.Output { + case "", "interleaved": + e.Output = output.Interleaved{} + case "group": + e.Output = output.Group{} + case "prefixed": + e.Output = output.Prefixed{} + default: + return fmt.Errorf(`task: output option "%s" not recognized`, e.Taskfile.Output) } e.taskCallCount = make(map[string]*int32, len(e.Taskfile.Tasks)) @@ -190,14 +206,19 @@ func (e *Executor) runCommand(ctx context.Context, t *taskfile.Task, call taskfi e.Logger.Errf(cmd.Cmd) } + stdOut := e.Output.WrapWriter(e.Stdout, t.Prefix) + stdErr := e.Output.WrapWriter(e.Stderr, t.Prefix) + defer stdOut.Close() + defer stdErr.Close() + return execext.RunCommand(&execext.RunCommandOptions{ Context: ctx, Command: cmd.Cmd, Dir: t.Dir, Env: getEnviron(t), Stdin: e.Stdin, - Stdout: e.Stdout, - Stderr: e.Stderr, + Stdout: stdOut, + Stderr: stdErr, }) } diff --git a/variables.go b/variables.go index ca0722bb..e5d8b411 100644 --- a/variables.go +++ b/variables.go @@ -39,6 +39,7 @@ func (e *Executor) CompiledTask(call taskfile.Call) (*taskfile.Task, error) { Env: r.ReplaceVars(origTask.Env), Silent: origTask.Silent, Method: r.Replace(origTask.Method), + Prefix: r.Replace(origTask.Prefix), } new.Dir, err = homedir.Expand(new.Dir) if err != nil { @@ -47,6 +48,9 @@ func (e *Executor) CompiledTask(call taskfile.Call) (*taskfile.Task, error) { if e.Dir != "" && !filepath.IsAbs(new.Dir) { new.Dir = filepath.Join(e.Dir, new.Dir) } + if new.Prefix == "" { + new.Prefix = new.Task + } for k, v := range new.Env { static, err := e.Compiler.HandleDynamicVar(v) if err != nil {