Repository: go-task/task
Pull request #1216 (2023-06-11 13:15:49)

feat: implement gentle force experiment draft
Note: This depends on #1214 

Implements a draft version of the [gentle force experiment](https://github.com/go-task/task/issues/1200). Based on the experiments branch and [PR](https://github.com/go-task/task/pull/1214).

This works by adding a new field to the `taskfile.Call` struct called `Direct` which is only set when a task is called directly from the CLI. Another new field is added to the `Executor` called `ForceAll`. Depending on whether or not the experiment is enabled, we set the Executor's `Force` and `ForceAll` fields and then we will skip fingerprinting if the task is direct and the executor is set to force, OR if the executor is set to force all.


Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 056671af..04df0e57 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -6,8 +6,10 @@
   - e.g. `taskfile.yml`, `taskfile.yaml`, `taskfile.dist.yml` &
     `taskfile.dist.yaml`
 - Bug fixed were made to the
-  [npm installation method](https://taskfile.dev/installation/#npm).
-  (#1190, by @sounisi5011).
+  [npm installation method](https://taskfile.dev/installation/#npm). (#1190, by
+  @sounisi5011).
+- Added the [gentle force experiment](https://taskfile.dev/experiments) as a
+  draft (#1200, #1216 by @pd93).
 
 ## v3.26.0 - 2023-06-10
 
diff --git a/args/args.go b/args/args.go
index e93e1ade..801ff88b 100644
--- a/args/args.go
+++ b/args/args.go
@@ -13,7 +13,7 @@ func ParseV3(args ...string) ([]taskfile.Call, *taskfile.Vars) {
 
 	for _, arg := range args {
 		if !strings.Contains(arg, "=") {
-			calls = append(calls, taskfile.Call{Task: arg})
+			calls = append(calls, taskfile.Call{Task: arg, Direct: true})
 			continue
 		}
 
@@ -22,7 +22,7 @@ func ParseV3(args ...string) ([]taskfile.Call, *taskfile.Vars) {
 	}
 
 	if len(calls) == 0 {
-		calls = append(calls, taskfile.Call{Task: "default"})
+		calls = append(calls, taskfile.Call{Task: "default", Direct: true})
 	}
 
 	return calls, globals
@@ -35,7 +35,7 @@ func ParseV2(args ...string) ([]taskfile.Call, *taskfile.Vars) {
 
 	for _, arg := range args {
 		if !strings.Contains(arg, "=") {
-			calls = append(calls, taskfile.Call{Task: arg})
+			calls = append(calls, taskfile.Call{Task: arg, Direct: true})
 			continue
 		}
 
@@ -52,7 +52,7 @@ func ParseV2(args ...string) ([]taskfile.Call, *taskfile.Vars) {
 	}
 
 	if len(calls) == 0 {
-		calls = append(calls, taskfile.Call{Task: "default"})
+		calls = append(calls, taskfile.Call{Task: "default", Direct: true})
 	}
 
 	return calls, globals
diff --git a/cmd/task/task.go b/cmd/task/task.go
index dde4ef2c..b8a9bdf2 100644
--- a/cmd/task/task.go
+++ b/cmd/task/task.go
@@ -15,6 +15,7 @@ import (
 	"github.com/go-task/task/v3"
 	"github.com/go-task/task/v3/args"
 	"github.com/go-task/task/v3/errors"
+	"github.com/go-task/task/v3/internal/experiments"
 	"github.com/go-task/task/v3/internal/logger"
 	"github.com/go-task/task/v3/internal/sort"
 	ver "github.com/go-task/task/v3/internal/version"
@@ -53,6 +54,7 @@ var flags struct {
 	taskSort    string
 	status      bool
 	force       bool
+	forceAll    bool
 	watch       bool
 	verbose     bool
 	silent      bool
@@ -78,7 +80,6 @@ func main() {
 			Verbose: flags.verbose,
 			Color:   flags.color,
 		}
-
 		if err, ok := err.(*errors.TaskRunError); ok && flags.exitCode {
 			l.Errf(logger.Red, "%v\n", err)
 			os.Exit(err.TaskExitCode())
@@ -110,7 +111,6 @@ func run() error {
 	pflag.BoolVarP(&flags.listJson, "json", "j", false, "Formats task list as JSON.")
 	pflag.StringVar(&flags.taskSort, "sort", "", "Changes the order of the tasks when listed. [default|alphanumeric|none].")
 	pflag.BoolVar(&flags.status, "status", false, "Exits with non-zero exit code if any of the given tasks is not up-to-date.")
-	pflag.BoolVarP(&flags.force, "force", "f", false, "Forces execution even when the task is up-to-date.")
 	pflag.BoolVarP(&flags.watch, "watch", "w", false, "Enables watch of the given task.")
 	pflag.BoolVarP(&flags.verbose, "verbose", "v", false, "Enables verbose mode.")
 	pflag.BoolVarP(&flags.silent, "silent", "s", false, "Disables echoing.")
@@ -129,6 +129,15 @@ func run() error {
 	pflag.IntVarP(&flags.concurrency, "concurrency", "C", 0, "Limit number tasks to run concurrently.")
 	pflag.DurationVarP(&flags.interval, "interval", "I", 0, "Interval to watch for changes.")
 	pflag.BoolVarP(&flags.global, "global", "g", false, "Runs global Taskfile, from $HOME/{T,t}askfile.{yml,yaml}.")
+
+	// Gentle force experiment will override the force flag and add a new force-all flag
+	if experiments.GentleForce {
+		pflag.BoolVarP(&flags.force, "force", "f", false, "Forces execution of the directly called task.")
+		pflag.BoolVar(&flags.forceAll, "force-all", false, "Forces execution of the called task and all its dependant tasks.")
+	} else {
+		pflag.BoolVarP(&flags.forceAll, "force", "f", false, "Forces execution even when the task is up-to-date.")
+	}
+
 	pflag.Parse()
 
 	if flags.version {
@@ -194,6 +203,7 @@ func run() error {
 
 	e := task.Executor{
 		Force:       flags.force,
+		ForceAll:    flags.forceAll,
 		Watch:       flags.watch,
 		Verbose:     flags.verbose,
 		Silent:      flags.silent,
diff --git a/docs/docs/experiments/experiments.md b/docs/docs/experiments/experiments.md
index 8d337b96..64d3c6da 100644
--- a/docs/docs/experiments/experiments.md
+++ b/docs/docs/experiments/experiments.md
@@ -70,6 +70,27 @@ version 3 as soon as possible.
 A list of changes between version 2 and version 3 are available in the [Task v3
 Release Notes][version-3-release-notes].
 
+### ![experiment] Gentle Force ([#1200](https://github.com/go-task/task/issues/1200))
+
+- Environment variable: `TASK_X_FORCE=1`
+- Breaks: `--force` flag
+
+The `--force` flag currently forces _all_ tasks to run regardless of the status
+checks. This can be useful, but we have found that most of the time users only
+expect the direct task they are calling to be forced and _not_ all of its
+dependant tasks.
+
+This experiment changes the `--force` flag to only force the directly called
+task. All dependant tasks will have their statuses checked as normal and will
+only run if Task considers them to be out of date. A new `--force-all` flag will
+also be added to maintain the current behavior for users that need this
+functionality.
+
+If you want to migrate, but continue to force all dependant tasks to run, you
+should replace all uses of the `--force` flag with `--force-all`. Alternatively,
+if you want to adopt the new behavior, you can continue to use the `--force`
+flag as you do now!
+
 <!-- prettier-ignore-start -->
 [breaking-change-proposal]: https://github.com/go-task/task/discussions/1191
 [deprecate-version-2-schema]: https://github.com/go-task/task/issues/1197
diff --git a/internal/experiments/experiments.go b/internal/experiments/experiments.go
index 1eb7cca7..df0e50e2 100644
--- a/internal/experiments/experiments.go
+++ b/internal/experiments/experiments.go
@@ -11,13 +11,13 @@ import (
 
 const envPrefix = "TASK_X_"
 
-var TestExperiment bool
+var GentleForce bool
 
 func init() {
 	if err := readDotEnv(); err != nil {
 		panic(err)
 	}
-	TestExperiment = parseEnv("TestExperiment")
+	GentleForce = parseEnv("GENTLE_FORCE")
 }
 
 func parseEnv(xName string) bool {
diff --git a/task.go b/task.go
index 5c564cc9..9bd8c8c0 100644
--- a/task.go
+++ b/task.go
@@ -50,6 +50,7 @@ type Executor struct {
 	TempDir     string
 	Entrypoint  string
 	Force       bool
+	ForceAll    bool
 	Watch       bool
 	Verbose     bool
 	Silent      bool
@@ -179,7 +180,8 @@ func (e *Executor) RunTask(ctx context.Context, call taskfile.Call) error {
 			return err
 		}
 
-		if !e.Force {
+		skipFingerprinting := e.ForceAll || (call.Direct && e.Force)
+		if !skipFingerprinting {
 			if err := ctx.Err(); err != nil {
 				return err
 			}
diff --git a/taskfile/call.go b/taskfile/call.go
index bb00e782..aaec725b 100644
--- a/taskfile/call.go
+++ b/taskfile/call.go
@@ -5,4 +5,5 @@ type Call struct {
 	Task   string
 	Vars   *Vars
 	Silent bool
+	Direct bool // Was the task called directly or via another task?
 }


Test patch:
diff --git a/args/args_test.go b/args/args_test.go
index 4404a4e9..3b74c7dd 100644
--- a/args/args_test.go
+++ b/args/args_test.go
@@ -20,17 +20,17 @@ func TestArgsV3(t *testing.T) {
 		{
 			Args: []string{"task-a", "task-b", "task-c"},
 			ExpectedCalls: []taskfile.Call{
-				{Task: "task-a"},
-				{Task: "task-b"},
-				{Task: "task-c"},
+				{Task: "task-a", Direct: true},
+				{Task: "task-b", Direct: true},
+				{Task: "task-c", Direct: true},
 			},
 		},
 		{
 			Args: []string{"task-a", "FOO=bar", "task-b", "task-c", "BAR=baz", "BAZ=foo"},
 			ExpectedCalls: []taskfile.Call{
-				{Task: "task-a"},
-				{Task: "task-b"},
-				{Task: "task-c"},
+				{Task: "task-a", Direct: true},
+				{Task: "task-b", Direct: true},
+				{Task: "task-c", Direct: true},
 			},
 			ExpectedGlobals: &taskfile.Vars{
 				OrderedMap: orderedmap.FromMapWithOrder(
@@ -46,7 +46,7 @@ func TestArgsV3(t *testing.T) {
 		{
 			Args: []string{"task-a", "CONTENT=with some spaces"},
 			ExpectedCalls: []taskfile.Call{
-				{Task: "task-a"},
+				{Task: "task-a", Direct: true},
 			},
 			ExpectedGlobals: &taskfile.Vars{
 				OrderedMap: orderedmap.FromMapWithOrder(
@@ -60,8 +60,8 @@ func TestArgsV3(t *testing.T) {
 		{
 			Args: []string{"FOO=bar", "task-a", "task-b"},
 			ExpectedCalls: []taskfile.Call{
-				{Task: "task-a"},
-				{Task: "task-b"},
+				{Task: "task-a", Direct: true},
+				{Task: "task-b", Direct: true},
 			},
 			ExpectedGlobals: &taskfile.Vars{
 				OrderedMap: orderedmap.FromMapWithOrder(
@@ -75,19 +75,19 @@ func TestArgsV3(t *testing.T) {
 		{
 			Args: nil,
 			ExpectedCalls: []taskfile.Call{
-				{Task: "default"},
+				{Task: "default", Direct: true},
 			},
 		},
 		{
 			Args: []string{},
 			ExpectedCalls: []taskfile.Call{
-				{Task: "default"},
+				{Task: "default", Direct: true},
 			},
 		},
 		{
 			Args: []string{"FOO=bar", "BAR=baz"},
 			ExpectedCalls: []taskfile.Call{
-				{Task: "default"},
+				{Task: "default", Direct: true},
 			},
 			ExpectedGlobals: &taskfile.Vars{
 				OrderedMap: orderedmap.FromMapWithOrder(
@@ -122,16 +122,17 @@ func TestArgsV2(t *testing.T) {
 		{
 			Args: []string{"task-a", "task-b", "task-c"},
 			ExpectedCalls: []taskfile.Call{
-				{Task: "task-a"},
-				{Task: "task-b"},
-				{Task: "task-c"},
+				{Task: "task-a", Direct: true},
+				{Task: "task-b", Direct: true},
+				{Task: "task-c", Direct: true},
 			},
 		},
 		{
 			Args: []string{"task-a", "FOO=bar", "task-b", "task-c", "BAR=baz", "BAZ=foo"},
 			ExpectedCalls: []taskfile.Call{
 				{
-					Task: "task-a",
+					Task:   "task-a",
+					Direct: true,
 					Vars: &taskfile.Vars{
 						OrderedMap: orderedmap.FromMapWithOrder(
 							map[string]taskfile.Var{
@@ -141,9 +142,10 @@ func TestArgsV2(t *testing.T) {
 						),
 					},
 				},
-				{Task: "task-b"},
+				{Task: "task-b", Direct: true},
 				{
-					Task: "task-c",
+					Task:   "task-c",
+					Direct: true,
 					Vars: &taskfile.Vars{
 						OrderedMap: orderedmap.FromMapWithOrder(
 							map[string]taskfile.Var{
@@ -160,7 +162,8 @@ func TestArgsV2(t *testing.T) {
 			Args: []string{"task-a", "CONTENT=with some spaces"},
 			ExpectedCalls: []taskfile.Call{
 				{
-					Task: "task-a",
+					Task:   "task-a",
+					Direct: true,
 					Vars: &taskfile.Vars{
 						OrderedMap: orderedmap.FromMapWithOrder(
 							map[string]taskfile.Var{
@@ -175,8 +178,8 @@ func TestArgsV2(t *testing.T) {
 		{
 			Args: []string{"FOO=bar", "task-a", "task-b"},
 			ExpectedCalls: []taskfile.Call{
-				{Task: "task-a"},
-				{Task: "task-b"},
+				{Task: "task-a", Direct: true},
+				{Task: "task-b", Direct: true},
 			},
 			ExpectedGlobals: &taskfile.Vars{
 				OrderedMap: orderedmap.FromMapWithOrder(
@@ -190,19 +193,19 @@ func TestArgsV2(t *testing.T) {
 		{
 			Args: nil,
 			ExpectedCalls: []taskfile.Call{
-				{Task: "default"},
+				{Task: "default", Direct: true},
 			},
 		},
 		{
 			Args: []string{},
 			ExpectedCalls: []taskfile.Call{
-				{Task: "default"},
+				{Task: "default", Direct: true},
 			},
 		},
 		{
 			Args: []string{"FOO=bar", "BAR=baz"},
 			ExpectedCalls: []taskfile.Call{
-				{Task: "default"},
+				{Task: "default", Direct: true},
 			},
 			ExpectedGlobals: &taskfile.Vars{
 				OrderedMap: orderedmap.FromMapWithOrder(
diff --git a/task_test.go b/task_test.go
index 8ea91d2a..b60dea7b 100644
--- a/task_test.go
+++ b/task_test.go
@@ -2120,3 +2120,49 @@ func TestSilence(t *testing.T) {
 
 	buff.Reset()
 }
+
+func TestForce(t *testing.T) {
+	tests := []struct {
+		name     string
+		env      map[string]string
+		force    bool
+		forceAll bool
+	}{
+		{
+			name:  "force",
+			force: true,
+		},
+		{
+			name:     "force-all",
+			forceAll: true,
+		},
+		{
+			name:  "force with gentle force experiment",
+			force: true,
+			env: map[string]string{
+				"TASK_X_GENTLE_FORCE": "1",
+			},
+		},
+		{
+			name:     "force-all with gentle force experiment",
+			forceAll: true,
+			env: map[string]string{
+				"TASK_X_GENTLE_FORCE": "1",
+			},
+		},
+	}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			var buff bytes.Buffer
+			e := task.Executor{
+				Dir:      "testdata/force",
+				Stdout:   &buff,
+				Stderr:   &buff,
+				Force:    tt.force,
+				ForceAll: tt.forceAll,
+			}
+			require.NoError(t, e.Setup())
+			require.NoError(t, e.Run(context.Background(), taskfile.Call{Task: "task-with-dep", Direct: true}))
+		})
+	}
+}
diff --git a/testdata/force/Taskfile.yml b/testdata/force/Taskfile.yml
new file mode 100644
index 00000000..31e7d3a4
--- /dev/null
+++ b/testdata/force/Taskfile.yml
@@ -0,0 +1,19 @@
+version: "3"
+
+tasks:
+  task-with-dep:
+    status: [ test true ]
+    deps: [ indirect ]
+    cmds:
+      - echo "direct"
+
+  task-with-subtask:
+    status: [ test true ]
+    cmds:
+      - task: indirect
+      - echo "direct"
+
+  indirect:
+    status: [ test true ]
+    cmds:
+      - echo "indirect"