Repository: go-task/task
Pull request #626 (2021-12-15 05:06:36)

Deferred Commands
Simple support for deferred commands.

Addresses #475

Deferred commands are commands that will run after all other commands, even if the task fails.
The semantics of deferred commands are identical to the semantics of defer statements in go:
1. Defers are added to a stack as they are encountered during task execution
2. After the last command completes, or if some command fails, the deferred tasks are executed in a last-in-first-out order

Given the following task definitions:
```yaml
do_some_setup:
  - echo 'doing setup'
  # ...
do_some_teardown:
  - echo 'doing teardown'
  # ...
do_some_stuff:
  cmds:
    - do_some_setup
    - defer: echo 'called after teardown'    # a deferred shell command
    - defer: { task: do_some_teardown, vars: {} }    # a deferred task call, indicated by the ^ prefix
    - defer: echo 'called before teardown'
    - echo 'oops we failed' && exit 1
    - defer: echo 'I will be skipped, because execution failed before we got here'
```

...and given the following call to `task`:
```sh
$ task do_some_stuff
```

...the expected output would be:
```
doing setup
oops we failed
called before teardown
doing teardown
called after teardown
```

mccolljr: Fixes #475
mccolljr: @andreynering just saw your review - thanks, I'll go through and apply the requested changes tomorrow and then ping you 

Patch:
diff --git a/task.go b/task.go
index 7021b77a..0e9ccd9f 100644
--- a/task.go
+++ b/task.go
@@ -8,6 +8,7 @@ import (
 	"os"
 	"sync"
 	"sync/atomic"
+	"time"
 
 	"github.com/go-task/task/v3/internal/compiler"
 	compilerv2 "github.com/go-task/task/v3/internal/compiler/v2"
@@ -339,6 +340,11 @@ func (e *Executor) RunTask(ctx context.Context, call taskfile.Call) error {
 		}
 
 		for i := range t.Cmds {
+			if t.Cmds[i].Defer {
+				defer e.runDeferred(t, call, i)
+				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", err2)
@@ -395,6 +401,14 @@ func (e *Executor) runDeps(ctx context.Context, t *taskfile.Task) error {
 	return g.Wait()
 }
 
+func (e *Executor) runDeferred(t *taskfile.Task, call taskfile.Call, i int) {
+	ctx, cancel := context.WithTimeout(context.Background(), time.Second*10)
+	defer cancel()
+	if err := e.runCommand(ctx, t, call, i); err != nil {
+		e.Logger.VerboseErrf(logger.Yellow, `task: ignored error in deferred cmd: %s`, err.Error())
+	}
+}
+
 func (e *Executor) runCommand(ctx context.Context, t *taskfile.Task, call taskfile.Call, i int) error {
 	cmd := t.Cmds[i]
 
diff --git a/taskfile/cmd.go b/taskfile/cmd.go
index 7166371b..4da7aadb 100644
--- a/taskfile/cmd.go
+++ b/taskfile/cmd.go
@@ -7,6 +7,7 @@ type Cmd struct {
 	Task        string
 	Vars        *Vars
 	IgnoreError bool
+	Defer       bool
 }
 
 // Dep is a task dependency
@@ -33,6 +34,26 @@ func (c *Cmd) UnmarshalYAML(unmarshal func(interface{}) error) error {
 		c.IgnoreError = cmdStruct.IgnoreError
 		return nil
 	}
+	var deferredCmd struct {
+		Defer string
+	}
+	if err := unmarshal(&deferredCmd); err == nil && deferredCmd.Defer != "" {
+		c.Defer = true
+		c.Cmd = deferredCmd.Defer
+		return nil
+	}
+	var deferredCall struct {
+		Defer struct {
+			Task string
+			Vars *Vars
+		}
+	}
+	if err := unmarshal(&deferredCall); err == nil && deferredCall.Defer.Task != "" {
+		c.Defer = true
+		c.Task = deferredCall.Defer.Task
+		c.Vars = deferredCall.Defer.Vars
+		return nil
+	}
 	var taskCall struct {
 		Task string
 		Vars *Vars
diff --git a/variables.go b/variables.go
index 9355e24e..6e69e4b6 100644
--- a/variables.go
+++ b/variables.go
@@ -102,6 +102,7 @@ func (e *Executor) compiledTask(call taskfile.Call, evaluateShVars bool) (*taskf
 				Cmd:         r.Replace(cmd.Cmd),
 				Vars:        r.ReplaceVars(cmd.Vars),
 				IgnoreError: cmd.IgnoreError,
+				Defer:       cmd.Defer,
 			})
 		}
 	}


Test patch:
diff --git a/task_test.go b/task_test.go
index f8e396f7..b82322ec 100644
--- a/task_test.go
+++ b/task_test.go
@@ -1044,6 +1044,32 @@ func TestRunOnlyRunsJobsHashOnce(t *testing.T) {
 	tt.Run(t)
 }
 
+func TestDeferredCmds(t *testing.T) {
+	const dir = "testdata/deferred"
+	var buff bytes.Buffer
+	e := task.Executor{
+		Dir:    dir,
+		Stdout: &buff,
+		Stderr: &buff,
+	}
+	assert.NoError(t, e.Setup())
+
+	expectedOutputOrder := strings.TrimSpace(`
+task: [task-2] echo 'cmd ran'
+cmd ran
+task: [task-2] exit 1
+task: [task-2] echo 'failing' && exit 2
+failing
+task: [task-2] echo 'echo ran'
+echo ran
+task: [task-1] echo 'task-1 ran successfully'
+task-1 ran successfully
+`)
+	assert.Error(t, e.Run(context.Background(), taskfile.Call{Task: "task-2"}))
+	fmt.Println(buff.String())
+	assert.Contains(t, buff.String(), expectedOutputOrder)
+}
+
 func TestIgnoreNilElements(t *testing.T) {
 	tests := []struct {
 		name string
diff --git a/taskfile/taskfile_test.go b/taskfile/taskfile_test.go
index e6b4470f..bb29d350 100644
--- a/taskfile/taskfile_test.go
+++ b/taskfile/taskfile_test.go
@@ -19,6 +19,8 @@ vars:
   PARAM1: VALUE1
   PARAM2: VALUE2
 `
+		yamlDeferredCall = `defer: { task: some_task, vars: { PARAM1: "var" } }`
+		yamlDeferredCmd  = `defer: echo 'test'`
 	)
 	tests := []struct {
 		content  string
@@ -41,6 +43,21 @@ vars:
 				},
 			}},
 		},
+		{
+			yamlDeferredCmd,
+			&taskfile.Cmd{},
+			&taskfile.Cmd{Cmd: "echo 'test'", Defer: true},
+		},
+		{
+			yamlDeferredCall,
+			&taskfile.Cmd{},
+			&taskfile.Cmd{Task: "some_task", Vars: &taskfile.Vars{
+				Keys: []string{"PARAM1"},
+				Mapping: map[string]taskfile.Var{
+					"PARAM1": taskfile.Var{Static: "var"},
+				},
+			}, Defer: true},
+		},
 		{
 			yamlDep,
 			&taskfile.Dep{},
diff --git a/testdata/deferred/Taskfile.yml b/testdata/deferred/Taskfile.yml
new file mode 100644
index 00000000..50392883
--- /dev/null
+++ b/testdata/deferred/Taskfile.yml
@@ -0,0 +1,12 @@
+version: "3"
+
+tasks:
+  task-1:
+    - echo 'task-1 ran {{.PARAM}}'
+
+  task-2:
+    - defer: { task: "task-1", vars: { PARAM: "successfully" } }
+    - defer: echo 'echo ran'
+    - defer: echo 'failing' && exit 2
+    - echo 'cmd ran'
+    - exit 1