diff --git a/docs/usage.md b/docs/usage.md index 34628713..3b94a23e 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -453,6 +453,41 @@ tasks: - echo "I will not run" ``` +### Limiting when tasks run + +If a task executed by multiple `cmds` or multiple `deps` you can limit +how many times it is executed each invocation of the `task` command using `run`. `run` can also be set at the root of the taskfile to change the behavior of all the tasks unless explicitly overridden + +Supported values for `run` + * `always` (default) always attempt to invoke the task regardless of the number of previous executions + * `once` only invoke this task once regardless of the number of times referred to + * `when_changed` only invokes the task once for a set of variables passed into the task + +```yaml +version: '3' +tasks: + default: + cmds: + - task: generate-file + vars: { CONTENT: '1' } + - task: generate-file + vars: { CONTENT: '2' } + - task: generate-file + vars: { CONTENT: '2' } + + generate-file: + run: when_changed + deps: + - install-deps + cmds: + - echo {{.CONTENT}} + + install-deps: + run: once + cmds: + - sleep 5 # long operation like installing packages +``` + ## Variables When doing interpolation of variables, Task will look for the below. diff --git a/go.mod b/go.mod index add0204e..5cf0c19a 100644 --- a/go.mod +++ b/go.mod @@ -5,6 +5,7 @@ require ( github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0 github.com/joho/godotenv v1.3.0 github.com/mattn/go-zglob v0.0.3 + github.com/mitchellh/hashstructure/v2 v2.0.2 github.com/radovskyb/watcher v1.0.7 github.com/spf13/pflag v1.0.5 github.com/stretchr/testify v1.7.0 diff --git a/go.sum b/go.sum index 739785b5..867896cf 100644 --- a/go.sum +++ b/go.sum @@ -27,6 +27,8 @@ github.com/mattn/go-isatty v0.0.12 h1:wuysRhFDzyxgEmMf5xjvJ2M9dZoWAXNNr5LSBS7uHX github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU= github.com/mattn/go-zglob v0.0.3 h1:6Ry4EYsScDyt5di4OI6xw1bYhOqfE5S33Z1OPy+d+To= github.com/mattn/go-zglob v0.0.3/go.mod h1:9fxibJccNxU2cnpIKLRRFA7zX7qhkJIQWBb449FYHOo= +github.com/mitchellh/hashstructure/v2 v2.0.2 h1:vGKWl0YJqUNxE8d+h8f6NJLcCJrgbhC4NcD46KavDd4= +github.com/mitchellh/hashstructure/v2 v2.0.2/go.mod h1:MG3aRVU/N29oo/V/IhBX8GR/zz4kQkprJgF2EVszyDE= github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= diff --git a/hash.go b/hash.go new file mode 100644 index 00000000..cd9579ac --- /dev/null +++ b/hash.go @@ -0,0 +1,28 @@ +package task + +import ( + "fmt" + + "github.com/go-task/task/v3/internal/hash" + "github.com/go-task/task/v3/taskfile" +) + +func (e *Executor) GetHash(t *taskfile.Task) (string, error) { + r := t.Run + if r == "" { + r = e.Taskfile.Run + } + + var h hash.HashFunc + switch r { + case "always": + h = hash.Empty + case "once": + h = hash.Name + case "when_changed": + h = hash.Hash + default: + return "", fmt.Errorf(`task: invalid run "%s"`, r) + } + return h(t) +} diff --git a/internal/hash/hash.go b/internal/hash/hash.go new file mode 100644 index 00000000..dd9799cd --- /dev/null +++ b/internal/hash/hash.go @@ -0,0 +1,23 @@ +package hash + +import ( + "fmt" + + "github.com/go-task/task/v3/taskfile" + "github.com/mitchellh/hashstructure/v2" +) + +type HashFunc func(*taskfile.Task) (string, error) + +func Empty(*taskfile.Task) (string, error) { + return "", nil +} + +func Name(t *taskfile.Task) (string, error) { + return t.Task, nil +} + +func Hash(t *taskfile.Task) (string, error) { + h, err := hashstructure.Hash(t, hashstructure.FormatV2, nil) + return fmt.Sprintf("%s:%d", t.Task, h), err +} diff --git a/task.go b/task.go index d13694ad..8d90a1da 100644 --- a/task.go +++ b/task.go @@ -58,6 +58,8 @@ type Executor struct { concurrencySemaphore chan struct{} taskCallCount map[string]*int32 mkdirMutexMap map[string]*sync.Mutex + execution map[string]context.Context + executionMutex sync.Mutex } // Run runs Task @@ -225,6 +227,10 @@ func (e *Executor) Setup() error { } } + if e.Taskfile.Run == "" { + e.Taskfile.Run = "always" + } + if v <= 2.1 { err := errors.New(`task: Taskfile option "ignore_error" is only available starting on Taskfile version v2.1`) @@ -260,6 +266,8 @@ func (e *Executor) Setup() error { } } + e.execution = make(map[string]context.Context) + e.taskCallCount = make(map[string]*int32, len(e.Taskfile.Tasks)) e.mkdirMutexMap = make(map[string]*sync.Mutex, len(e.Taskfile.Tasks)) for k := range e.Taskfile.Tasks { @@ -286,6 +294,17 @@ func (e *Executor) RunTask(ctx context.Context, call taskfile.Call) error { release := e.acquireConcurrencyLimit() defer release() + started, ctx, cancel, err := e.startExecution(ctx, t) + if err != nil { + return err + } + defer cancel() + + if !started { + <-ctx.Done() + return nil + } + if err := e.runDeps(ctx, t); err != nil { return err } @@ -445,3 +464,25 @@ func getEnviron(t *taskfile.Task) []string { return environ } + +func (e *Executor) startExecution(innerCtx context.Context, t *taskfile.Task) (bool, context.Context, context.CancelFunc, error) { + h, err := e.GetHash(t) + if err != nil { + return true, nil, nil, err + } + + if h == "" { + return true, innerCtx, func() {}, nil + } + + e.executionMutex.Lock() + defer e.executionMutex.Unlock() + ctx, ok := e.execution[h] + if ok { + return false, ctx, func() {}, nil + } + + ctx, cancel := context.WithCancel(innerCtx) + e.execution[h] = ctx + return true, ctx, cancel, nil +} diff --git a/task_test.go b/task_test.go index 52282aca..d35b8a39 100644 --- a/task_test.go +++ b/task_test.go @@ -979,3 +979,14 @@ func TestExitImmediately(t *testing.T) { assert.Error(t, e.Run(context.Background(), taskfile.Call{Task: "default"})) assert.Contains(t, buff.String(), `"this_should_fail": executable file not found in $PATH`) } + +func TestRunOnlyRunsJobsHashOnce(t *testing.T) { + tt := fileContentTest{ + Dir: "testdata/run", + Target: "generate-hash", + Files: map[string]string{ + "hash.txt": "starting 1\n1\n2\n", + }, + } + tt.Run(t) +} diff --git a/taskfile/task.go b/taskfile/task.go index 4acae7b8..39a554fa 100644 --- a/taskfile/task.go +++ b/taskfile/task.go @@ -22,6 +22,7 @@ type Task struct { Method string Prefix string IgnoreError bool + Run string } func (t *Task) Name() string { @@ -61,6 +62,7 @@ func (t *Task) UnmarshalYAML(unmarshal func(interface{}) error) error { Method string Prefix string IgnoreError bool `yaml:"ignore_error"` + Run string } if err := unmarshal(&task); err != nil { return err @@ -81,5 +83,6 @@ func (t *Task) UnmarshalYAML(unmarshal func(interface{}) error) error { t.Method = task.Method t.Prefix = task.Prefix t.IgnoreError = task.IgnoreError + t.Run = task.Run return nil } diff --git a/taskfile/taskfile.go b/taskfile/taskfile.go index b3b8a556..9fe45a06 100644 --- a/taskfile/taskfile.go +++ b/taskfile/taskfile.go @@ -17,6 +17,7 @@ type Taskfile struct { Tasks Tasks Silent bool Dotenv []string + Run string } // UnmarshalYAML implements yaml.Unmarshaler interface @@ -32,6 +33,7 @@ func (tf *Taskfile) UnmarshalYAML(unmarshal func(interface{}) error) error { Tasks Tasks Silent bool Dotenv []string + Run string } if err := unmarshal(&taskfile); err != nil { return err @@ -46,6 +48,7 @@ func (tf *Taskfile) UnmarshalYAML(unmarshal func(interface{}) error) error { tf.Tasks = taskfile.Tasks tf.Silent = taskfile.Silent tf.Dotenv = taskfile.Dotenv + tf.Run = taskfile.Run if tf.Expansions <= 0 { tf.Expansions = 2 } diff --git a/testdata/run/.gitignore b/testdata/run/.gitignore new file mode 100644 index 00000000..2211df63 --- /dev/null +++ b/testdata/run/.gitignore @@ -0,0 +1 @@ +*.txt diff --git a/testdata/run/Taskfile.yml b/testdata/run/Taskfile.yml new file mode 100644 index 00000000..026b570c --- /dev/null +++ b/testdata/run/Taskfile.yml @@ -0,0 +1,24 @@ +version: '3' +run: when_changed + +tasks: + generate-hash: + - rm -f hash.txt + - task: input-content + vars: { CONTENT: '1' } + - task: input-content + vars: { CONTENT: '2' } + - task: input-content + vars: { CONTENT: '2' } + + input-content: + deps: + - task: create-output + vars: { CONTENT: '1' } + cmds: + - echo {{.CONTENT}} >> hash.txt + + create-output: + run: once + cmds: + - echo starting {{.CONTENT}} >> hash.txt diff --git a/variables.go b/variables.go index 8c13154e..0e7ff4c0 100644 --- a/variables.go +++ b/variables.go @@ -59,6 +59,7 @@ func (e *Executor) compiledTask(call taskfile.Call, evaluateShVars bool) (*taskf Method: r.Replace(origTask.Method), Prefix: r.Replace(origTask.Prefix), IgnoreError: origTask.IgnoreError, + Run: r.Replace(origTask.Run), } new.Dir, err = execext.Expand(new.Dir) if err != nil {