1
0
mirror of https://github.com/go-task/task.git synced 2025-02-03 13:22:11 +02:00

refactor: decouple fingerprinting from executor (#1039)

This commit is contained in:
Pete Davison 2023-03-10 18:27:30 +00:00 committed by GitHub
parent c64f8818be
commit 0838d48ee3
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
24 changed files with 734 additions and 241 deletions

View File

@ -13,6 +13,7 @@
- Fixed bug where `.task/checksum` file was sometimes not being created when
task also declares a `status:`
([#840](https://github.com/go-task/task/issues/840), [#1035](https://github.com/go-task/task/pull/1035) by @harelwa, [#1037](https://github.com/go-task/task/pull/1037) by @pd93).
- Refactored and decoupled fingerprinting from the main Task executor ([#1039](https://github.com/go-task/task/issues/1039) by @pd93).
- Fixed deadlock issue when using `run: once`
([#715](https://github.com/go-task/task/issues/715), [#1025](https://github.com/go-task/task/pull/1025) by @theunrepentantgeek).

View File

@ -27,6 +27,24 @@ tasks:
GIT_COMMIT:
sh: git log -n 1 --format=%h
generate:
desc: Runs Go generate to create mocks
aliases: [gen, g]
deps: [install:mockgen]
sources:
- "internal/fingerprint/checker.go"
generates:
- "internal/fingerprint/checker_mock.go"
cmds:
- mockgen -source=internal/fingerprint/checker.go -destination=internal/fingerprint/checker_mock.go -package=fingerprint
install:mockgen:
desc: Installs mockgen; a tool to generate mock files
status:
- command -v mockgen &>/dev/null
cmds:
- go install github.com/golang/mock/mockgen@latest
mod:
desc: Downloads and tidy Go modules
cmds:

1
go.mod
View File

@ -6,6 +6,7 @@ require (
github.com/Masterminds/semver/v3 v3.2.0
github.com/fatih/color v1.14.1
github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0
github.com/golang/mock v1.6.0
github.com/joho/godotenv v1.5.1
github.com/mattn/go-zglob v0.0.4
github.com/mitchellh/hashstructure/v2 v2.0.2

27
go.sum
View File

@ -9,6 +9,8 @@ github.com/fatih/color v1.14.1/go.mod h1:2oHN61fhTpgcxD3TSWCgKDiH1+x4OiDVVGH8Wlg
github.com/frankban/quicktest v1.14.4 h1:g2rn0vABPOOXmZUj+vbmUp0lPoXEMuhTpIluN0XL9UY=
github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0 h1:p104kn46Q8WdvHunIJ9dAyjPVtrBPhSr3KT2yUst43I=
github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0/go.mod h1:fyg7847qk6SyHyPtNmDHnmrv/HOrqktSC+C9fM+CJOE=
github.com/golang/mock v1.6.0 h1:ErTB+efbowRARo13NNdxyJji2egdxLGQhRaY+DUumQc=
github.com/golang/mock v1.6.0/go.mod h1:p6yTPP+5HYm5mzsMV8JkE6ZKdX+/wYM6Hr+LicevLPs=
github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
@ -40,17 +42,38 @@ github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
golang.org/x/exp v0.0.0-20220930202632-ec3f01382ef9 h1:RjggHMcaTVp0LOVZcW0bo8alwHrOaCrGUDgfWUHhnN4=
golang.org/x/exp v0.0.0-20220930202632-ec3f01382ef9/go.mod h1:cyybsKvd6eL0RnXn6p/Grxp8F5bW7iYuBgsNCOHpMYE=
github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/exp v0.0.0-20230212135524-a684f29349b6 h1:Ic9KukPQ7PegFzHckNiMTQXGgEszA7mY2Fn4ZMtnMbw=
golang.org/x/exp v0.0.0-20230212135524-a684f29349b6/go.mod h1:CxIveKay+FTh1D0yPZemJVgC/95VzuuOLq5Qi4xnoYc=
golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0 h1:wsuoTGHzEhffawBOhz5CYhcrV4IdKZbEyZjBMuTp12o=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.3.0 h1:w8ZOecv6NaNa/zC8944JTU3vz4u6Lagfk4RPQxv92NQ=
golang.org/x/sys v0.3.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.3.0 h1:qoo4akIqOcDME5bhc/NgxUdovd6BSS2uMsVjB56q1xI=
golang.org/x/term v0.3.0/go.mod h1:q750SLmJuPmVoN1blW3UFBPREJfb1KmY3vwxfr+nFDA=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.1/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=

13
help.go
View File

@ -12,6 +12,7 @@ import (
"text/tabwriter"
"github.com/go-task/task/v3/internal/editors"
"github.com/go-task/task/v3/internal/fingerprint"
"github.com/go-task/task/v3/internal/logger"
"github.com/go-task/task/v3/taskfile"
)
@ -148,7 +149,17 @@ func (e *Executor) ToEditorOutput(tasks []*taskfile.Task) (*editors.Output, erro
Tasks: make([]editors.Task, len(tasks)),
}
for i, t := range tasks {
upToDate, err := e.isTaskUpToDate(context.Background(), t)
// Get the fingerprinting method to use
method := e.Taskfile.Method
if t.Method != "" {
method = t.Method
}
upToDate, err := fingerprint.IsTaskUpToDate(context.Background(), t,
fingerprint.WithMethod(method),
fingerprint.WithTempDir(e.TempDir),
fingerprint.WithDry(e.Dry),
fingerprint.WithLogger(e.Logger),
)
if err != nil {
return nil, err
}

31
internal/env/env.go vendored Normal file
View File

@ -0,0 +1,31 @@
package env
import (
"fmt"
"os"
"github.com/go-task/task/v3/taskfile"
)
func Get(t *taskfile.Task) []string {
if t.Env == nil {
return nil
}
environ := os.Environ()
for k, v := range t.Env.ToCacheMap() {
str, isString := v.(string)
if !isString {
continue
}
if _, alreadySet := os.LookupEnv(k); alreadySet {
continue
}
environ = append(environ, fmt.Sprintf("%s=%s", k, str))
}
return environ
}

View File

@ -0,0 +1,20 @@
package fingerprint
import (
"context"
"github.com/go-task/task/v3/taskfile"
)
// StatusCheckable defines any type that can check if the status of a task is up-to-date.
type StatusCheckable interface {
IsUpToDate(ctx context.Context, t *taskfile.Task) (bool, error)
}
// SourcesCheckable defines any type that can check if the sources of a task are up-to-date.
type SourcesCheckable interface {
IsUpToDate(t *taskfile.Task) (bool, error)
Value(t *taskfile.Task) (interface{}, error)
OnError(t *taskfile.Task) error
Kind() string
}

View File

@ -0,0 +1,132 @@
// Code generated by MockGen. DO NOT EDIT.
// Source: checker.go
// Package fingerprint is a generated GoMock package.
package fingerprint
import (
context "context"
reflect "reflect"
taskfile "github.com/go-task/task/v3/taskfile"
gomock "github.com/golang/mock/gomock"
)
// MockStatusCheckable is a mock of StatusCheckable interface.
type MockStatusCheckable struct {
ctrl *gomock.Controller
recorder *MockStatusCheckableMockRecorder
}
// MockStatusCheckableMockRecorder is the mock recorder for MockStatusCheckable.
type MockStatusCheckableMockRecorder struct {
mock *MockStatusCheckable
}
// NewMockStatusCheckable creates a new mock instance.
func NewMockStatusCheckable(ctrl *gomock.Controller) *MockStatusCheckable {
mock := &MockStatusCheckable{ctrl: ctrl}
mock.recorder = &MockStatusCheckableMockRecorder{mock}
return mock
}
// EXPECT returns an object that allows the caller to indicate expected use.
func (m *MockStatusCheckable) EXPECT() *MockStatusCheckableMockRecorder {
return m.recorder
}
// IsUpToDate mocks base method.
func (m *MockStatusCheckable) IsUpToDate(ctx context.Context, t *taskfile.Task) (bool, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "IsUpToDate", ctx, t)
ret0, _ := ret[0].(bool)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// IsUpToDate indicates an expected call of IsUpToDate.
func (mr *MockStatusCheckableMockRecorder) IsUpToDate(ctx, t interface{}) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "IsUpToDate", reflect.TypeOf((*MockStatusCheckable)(nil).IsUpToDate), ctx, t)
}
// MockSourcesCheckable is a mock of SourcesCheckable interface.
type MockSourcesCheckable struct {
ctrl *gomock.Controller
recorder *MockSourcesCheckableMockRecorder
}
// MockSourcesCheckableMockRecorder is the mock recorder for MockSourcesCheckable.
type MockSourcesCheckableMockRecorder struct {
mock *MockSourcesCheckable
}
// NewMockSourcesCheckable creates a new mock instance.
func NewMockSourcesCheckable(ctrl *gomock.Controller) *MockSourcesCheckable {
mock := &MockSourcesCheckable{ctrl: ctrl}
mock.recorder = &MockSourcesCheckableMockRecorder{mock}
return mock
}
// EXPECT returns an object that allows the caller to indicate expected use.
func (m *MockSourcesCheckable) EXPECT() *MockSourcesCheckableMockRecorder {
return m.recorder
}
// IsUpToDate mocks base method.
func (m *MockSourcesCheckable) IsUpToDate(t *taskfile.Task) (bool, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "IsUpToDate", t)
ret0, _ := ret[0].(bool)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// IsUpToDate indicates an expected call of IsUpToDate.
func (mr *MockSourcesCheckableMockRecorder) IsUpToDate(t interface{}) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "IsUpToDate", reflect.TypeOf((*MockSourcesCheckable)(nil).IsUpToDate), t)
}
// Kind mocks base method.
func (m *MockSourcesCheckable) Kind() string {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "Kind")
ret0, _ := ret[0].(string)
return ret0
}
// Kind indicates an expected call of Kind.
func (mr *MockSourcesCheckableMockRecorder) Kind() *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Kind", reflect.TypeOf((*MockSourcesCheckable)(nil).Kind))
}
// OnError mocks base method.
func (m *MockSourcesCheckable) OnError(t *taskfile.Task) error {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "OnError", t)
ret0, _ := ret[0].(error)
return ret0
}
// OnError indicates an expected call of OnError.
func (mr *MockSourcesCheckableMockRecorder) OnError(t interface{}) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "OnError", reflect.TypeOf((*MockSourcesCheckable)(nil).OnError), t)
}
// Value mocks base method.
func (m *MockSourcesCheckable) Value(t *taskfile.Task) (interface{}, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "Value", t)
ret0, _ := ret[0].(interface{})
ret1, _ := ret[1].(error)
return ret0, ret1
}
// Value indicates an expected call of Value.
func (mr *MockSourcesCheckableMockRecorder) Value(t interface{}) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Value", reflect.TypeOf((*MockSourcesCheckable)(nil).Value), t)
}

View File

@ -1,4 +1,4 @@
package status
package fingerprint
import (
"os"

View File

@ -0,0 +1,16 @@
package fingerprint
import "fmt"
func NewSourcesChecker(method, tempDir string, dry bool) (SourcesCheckable, error) {
switch method {
case "timestamp":
return NewTimestampChecker(tempDir, dry), nil
case "checksum":
return NewChecksumChecker(tempDir, dry), nil
case "none":
return NoneChecker{}, nil
default:
return nil, fmt.Errorf(`task: invalid method "%s"`, method)
}
}

View File

@ -1,4 +1,4 @@
package status
package fingerprint
import (
"crypto/md5"
@ -10,51 +10,54 @@ import (
"strings"
"github.com/go-task/task/v3/internal/filepathext"
"github.com/go-task/task/v3/taskfile"
)
// Checksum validades if a task is up to date by calculating its source
// ChecksumChecker validates if a task is up to date by calculating its source
// files checksum
type Checksum struct {
TempDir string
TaskDir string
Task string
Sources []string
Generates []string
Dry bool
type ChecksumChecker struct {
tempDir string
dry bool
}
// IsUpToDate implements the Checker interface
func (c *Checksum) IsUpToDate() (bool, error) {
if len(c.Sources) == 0 {
func NewChecksumChecker(tempDir string, dry bool) *ChecksumChecker {
return &ChecksumChecker{
tempDir: tempDir,
dry: dry,
}
}
func (checker *ChecksumChecker) IsUpToDate(t *taskfile.Task) (bool, error) {
if len(t.Sources) == 0 {
return false, nil
}
checksumFile := c.checksumFilePath()
checksumFile := checker.checksumFilePath(t)
data, _ := os.ReadFile(checksumFile)
oldMd5 := strings.TrimSpace(string(data))
sources, err := globs(c.TaskDir, c.Sources)
sources, err := globs(t.Dir, t.Sources)
if err != nil {
return false, err
}
newMd5, err := c.checksum(sources...)
newMd5, err := checker.checksum(sources...)
if err != nil {
return false, nil
}
if !c.Dry {
_ = os.MkdirAll(filepathext.SmartJoin(c.TempDir, "checksum"), 0o755)
if !checker.dry {
_ = os.MkdirAll(filepathext.SmartJoin(checker.tempDir, "checksum"), 0o755)
if err = os.WriteFile(checksumFile, []byte(newMd5+"\n"), 0o644); err != nil {
return false, err
}
}
if len(c.Generates) > 0 {
if len(t.Generates) > 0 {
// For each specified 'generates' field, check whether the files actually exist
for _, g := range c.Generates {
generates, err := Glob(c.TaskDir, g)
for _, g := range t.Generates {
generates, err := Glob(t.Dir, g)
if os.IsNotExist(err) {
return false, nil
}
@ -70,7 +73,22 @@ func (c *Checksum) IsUpToDate() (bool, error) {
return oldMd5 == newMd5, nil
}
func (c *Checksum) checksum(files ...string) (string, error) {
func (checker *ChecksumChecker) Value(t *taskfile.Task) (interface{}, error) {
return checker.checksum()
}
func (checker *ChecksumChecker) OnError(t *taskfile.Task) error {
if len(t.Sources) == 0 {
return nil
}
return os.Remove(checker.checksumFilePath(t))
}
func (*ChecksumChecker) Kind() string {
return "checksum"
}
func (c *ChecksumChecker) checksum(files ...string) (string, error) {
h := md5.New()
for _, f := range files {
@ -91,31 +109,13 @@ func (c *Checksum) checksum(files ...string) (string, error) {
return fmt.Sprintf("%x", h.Sum(nil)), nil
}
// Value implements the Checker Interface
func (c *Checksum) Value() (interface{}, error) {
return c.checksum()
}
// OnError implements the Checker interface
func (c *Checksum) OnError() error {
if len(c.Sources) == 0 {
return nil
}
return os.Remove(c.checksumFilePath())
}
// Kind implements the Checker Interface
func (*Checksum) Kind() string {
return "checksum"
}
func (c *Checksum) checksumFilePath() string {
return filepath.Join(c.TempDir, "checksum", normalizeFilename(c.Task))
func (checker *ChecksumChecker) checksumFilePath(t *taskfile.Task) string {
return filepath.Join(checker.tempDir, "checksum", normalizeFilename(t.Name()))
}
var checksumFilenameRegexp = regexp.MustCompile("[^A-z0-9]")
// replaces invalid caracters on filenames with "-"
// replaces invalid characters on filenames with "-"
func normalizeFilename(f string) string {
return checksumFilenameRegexp.ReplaceAllString(f, "-")
}

View File

@ -1,4 +1,4 @@
package status
package fingerprint
import (
"testing"

View File

@ -0,0 +1,23 @@
package fingerprint
import "github.com/go-task/task/v3/taskfile"
// NoneChecker is a no-op Checker.
// It will always report that the task is not up-to-date.
type NoneChecker struct{}
func (NoneChecker) IsUpToDate(t *taskfile.Task) (bool, error) {
return false, nil
}
func (NoneChecker) Value(t *taskfile.Task) (interface{}, error) {
return "", nil
}
func (NoneChecker) OnError(t *taskfile.Task) error {
return nil
}
func (NoneChecker) Kind() string {
return "none"
}

View File

@ -1,24 +1,29 @@
package status
package fingerprint
import (
"os"
"path/filepath"
"time"
"github.com/go-task/task/v3/taskfile"
)
// Timestamp checks if any source change compared with the generated files,
// TimestampChecker checks if any source change compared with the generated files,
// using file modifications timestamps.
type Timestamp struct {
TempDir string
Task string
Dir string
Sources []string
Generates []string
Dry bool
type TimestampChecker struct {
tempDir string
dry bool
}
func NewTimestampChecker(tempDir string, dry bool) *TimestampChecker {
return &TimestampChecker{
tempDir: tempDir,
dry: dry,
}
}
// IsUpToDate implements the Checker interface
func (t *Timestamp) IsUpToDate() (bool, error) {
func (checker *TimestampChecker) IsUpToDate(t *taskfile.Task) (bool, error) {
if len(t.Sources) == 0 {
return false, nil
}
@ -32,7 +37,7 @@ func (t *Timestamp) IsUpToDate() (bool, error) {
return false, nil
}
timestampFile := t.timestampFilePath()
timestampFile := checker.timestampFilePath(t)
// If the file exists, add the file path to the generates.
// If the generate file is old, the task will be executed.
@ -41,7 +46,7 @@ func (t *Timestamp) IsUpToDate() (bool, error) {
generates = append(generates, timestampFile)
} else {
// Create the timestamp file for the next execution when the file does not exist.
if !t.Dry {
if !checker.dry {
if err := os.MkdirAll(filepath.Dir(timestampFile), 0o755); err != nil {
return false, err
}
@ -70,7 +75,7 @@ func (t *Timestamp) IsUpToDate() (bool, error) {
}
// Modify the metadata of the file to the the current time.
if !t.Dry {
if !checker.dry {
if err := os.Chtimes(timestampFile, taskTime, taskTime); err != nil {
return false, err
}
@ -79,12 +84,12 @@ func (t *Timestamp) IsUpToDate() (bool, error) {
return !shouldUpdate, nil
}
func (t *Timestamp) Kind() string {
func (checker *TimestampChecker) Kind() string {
return "timestamp"
}
// Value implements the Checker Interface
func (t *Timestamp) Value() (interface{}, error) {
func (checker *TimestampChecker) Value(t *taskfile.Task) (interface{}, error) {
sources, err := globs(t.Dir, t.Sources)
if err != nil {
return time.Now(), err
@ -137,10 +142,10 @@ func anyFileNewerThan(files []string, givenTime time.Time) (bool, error) {
}
// OnError implements the Checker interface
func (*Timestamp) OnError() error {
func (*TimestampChecker) OnError(t *taskfile.Task) error {
return nil
}
func (t *Timestamp) timestampFilePath() string {
return filepath.Join(t.TempDir, "timestamp", normalizeFilename(t.Task))
func (checker *TimestampChecker) timestampFilePath(t *taskfile.Task) string {
return filepath.Join(checker.tempDir, "timestamp", normalizeFilename(t.Task))
}

View File

@ -0,0 +1,36 @@
package fingerprint
import (
"context"
"github.com/go-task/task/v3/internal/env"
"github.com/go-task/task/v3/internal/execext"
"github.com/go-task/task/v3/internal/logger"
"github.com/go-task/task/v3/taskfile"
)
type StatusChecker struct {
logger *logger.Logger
}
func NewStatusChecker(logger *logger.Logger) StatusCheckable {
return &StatusChecker{
logger: logger,
}
}
func (checker *StatusChecker) IsUpToDate(ctx context.Context, t *taskfile.Task) (bool, error) {
for _, s := range t.Status {
err := execext.RunCommand(ctx, &execext.RunCommandOptions{
Command: s,
Dir: t.Dir,
Env: env.Get(t),
})
if err != nil {
checker.logger.VerboseOutf(logger.Yellow, "task: status command %s exited non-zero: %s", s, err)
return false, nil
}
checker.logger.VerboseOutf(logger.Yellow, "task: status command %s exited zero", s)
}
return true, nil
}

View File

@ -0,0 +1,132 @@
package fingerprint
import (
"context"
"github.com/go-task/task/v3/internal/logger"
"github.com/go-task/task/v3/taskfile"
)
type (
CheckerOption func(*CheckerConfig)
CheckerConfig struct {
method string
dry bool
tempDir string
logger *logger.Logger
statusChecker StatusCheckable
sourcesChecker SourcesCheckable
}
)
func WithMethod(method string) CheckerOption {
return func(config *CheckerConfig) {
config.method = method
}
}
func WithDry(dry bool) CheckerOption {
return func(config *CheckerConfig) {
config.dry = dry
}
}
func WithTempDir(tempDir string) CheckerOption {
return func(config *CheckerConfig) {
config.tempDir = tempDir
}
}
func WithLogger(logger *logger.Logger) CheckerOption {
return func(config *CheckerConfig) {
config.logger = logger
}
}
func WithStatusChecker(checker StatusCheckable) CheckerOption {
return func(config *CheckerConfig) {
config.statusChecker = checker
}
}
func WithSourcesChecker(checker SourcesCheckable) CheckerOption {
return func(config *CheckerConfig) {
config.sourcesChecker = checker
}
}
func IsTaskUpToDate(
ctx context.Context,
t *taskfile.Task,
opts ...CheckerOption,
) (bool, error) {
var statusUpToDate bool
var sourcesUpToDate bool
var err error
// Default config
config := &CheckerConfig{
method: "none",
tempDir: "",
dry: false,
logger: nil,
statusChecker: nil,
sourcesChecker: nil,
}
// Apply functional options
for _, opt := range opts {
opt(config)
}
// If no status checker was given, set up the default one
if config.statusChecker == nil {
config.statusChecker = NewStatusChecker(config.logger)
}
// If no sources checker was given, set up the default one
if config.sourcesChecker == nil {
config.sourcesChecker, err = NewSourcesChecker(config.method, config.tempDir, config.dry)
if err != nil {
return false, err
}
}
statusIsSet := len(t.Status) != 0
sourcesIsSet := len(t.Sources) != 0
// If status is set, check if it is up-to-date
if statusIsSet {
statusUpToDate, err = config.statusChecker.IsUpToDate(ctx, t)
if err != nil {
return false, err
}
}
// If sources is set, check if they are up-to-date
if sourcesIsSet {
sourcesUpToDate, err = config.sourcesChecker.IsUpToDate(t)
if err != nil {
return false, err
}
}
// If both status and sources are set, the task is up-to-date if both are up-to-date
if statusIsSet && sourcesIsSet {
return statusUpToDate && sourcesUpToDate, nil
}
// If only status is set, the task is up-to-date if the status is up-to-date
if statusIsSet {
return statusUpToDate, nil
}
// If only sources is set, the task is up-to-date if the sources are up-to-date
if sourcesIsSet {
return sourcesUpToDate, nil
}
// If no status or sources are set, the task should always run
// i.e. it is never considered "up-to-date"
return false, nil
}

View File

@ -0,0 +1,174 @@
package fingerprint
import (
"context"
"testing"
"github.com/golang/mock/gomock"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/go-task/task/v3/taskfile"
)
// TruthTable
//
// | Status up-to-date | Sources up-to-date | Task is up-to-date |
// | ----------------- | ------------------ | ------------------ |
// | not set | not set | false |
// | not set | true | true |
// | not set | false | false |
// | true | not set | true |
// | true | true | true |
// | true | false | false |
// | false | not set | false |
// | false | true | false |
// | false | false | false |
func TestIsTaskUpToDate(t *testing.T) {
tests := []struct {
name string
task *taskfile.Task
setupMockStatusChecker func(m *MockStatusCheckable)
setupMockSourcesChecker func(m *MockSourcesCheckable)
expected bool
}{
{
name: "expect FALSE when no status or sources are defined",
task: &taskfile.Task{
Status: nil,
Sources: nil,
},
setupMockStatusChecker: nil,
setupMockSourcesChecker: nil,
expected: false,
},
{
name: "expect TRUE when no status is defined and sources are up-to-date",
task: &taskfile.Task{
Status: nil,
Sources: []string{"sources"},
},
setupMockStatusChecker: nil,
setupMockSourcesChecker: func(m *MockSourcesCheckable) {
m.EXPECT().IsUpToDate(gomock.Any()).Return(true, nil)
},
expected: true,
},
{
name: "expect FALSE when no status is defined and sources are NOT up-to-date",
task: &taskfile.Task{
Status: nil,
Sources: []string{"sources"},
},
setupMockStatusChecker: nil,
setupMockSourcesChecker: func(m *MockSourcesCheckable) {
m.EXPECT().IsUpToDate(gomock.Any()).Return(false, nil)
},
expected: false,
},
{
name: "expect TRUE when status is up-to-date and sources are not defined",
task: &taskfile.Task{
Status: []string{"status"},
Sources: nil,
},
setupMockStatusChecker: func(m *MockStatusCheckable) {
m.EXPECT().IsUpToDate(gomock.Any(), gomock.Any()).Return(true, nil)
},
setupMockSourcesChecker: nil,
expected: true,
},
{
name: "expect TRUE when status and sources are up-to-date",
task: &taskfile.Task{
Status: []string{"status"},
Sources: []string{"sources"},
},
setupMockStatusChecker: func(m *MockStatusCheckable) {
m.EXPECT().IsUpToDate(gomock.Any(), gomock.Any()).Return(true, nil)
},
setupMockSourcesChecker: func(m *MockSourcesCheckable) {
m.EXPECT().IsUpToDate(gomock.Any()).Return(true, nil)
},
expected: true,
},
{
name: "expect FALSE when status is up-to-date, but sources are NOT up-to-date",
task: &taskfile.Task{
Status: []string{"status"},
Sources: []string{"sources"},
},
setupMockStatusChecker: func(m *MockStatusCheckable) {
m.EXPECT().IsUpToDate(gomock.Any(), gomock.Any()).Return(true, nil)
},
setupMockSourcesChecker: func(m *MockSourcesCheckable) {
m.EXPECT().IsUpToDate(gomock.Any()).Return(false, nil)
},
expected: false,
},
{
name: "expect FALSE when status is NOT up-to-date and sources are not defined",
task: &taskfile.Task{
Status: []string{"status"},
Sources: nil,
},
setupMockStatusChecker: func(m *MockStatusCheckable) {
m.EXPECT().IsUpToDate(gomock.Any(), gomock.Any()).Return(false, nil)
},
setupMockSourcesChecker: nil,
expected: false,
},
{
name: "expect FALSE when status is NOT up-to-date, but sources are up-to-date",
task: &taskfile.Task{
Status: []string{"status"},
Sources: []string{"sources"},
},
setupMockStatusChecker: func(m *MockStatusCheckable) {
m.EXPECT().IsUpToDate(gomock.Any(), gomock.Any()).Return(false, nil)
},
setupMockSourcesChecker: func(m *MockSourcesCheckable) {
m.EXPECT().IsUpToDate(gomock.Any()).Return(true, nil)
},
expected: false,
},
{
name: "expect FALSE when status and sources are NOT up-to-date",
task: &taskfile.Task{
Status: []string{"status"},
Sources: []string{"sources"},
},
setupMockStatusChecker: func(m *MockStatusCheckable) {
m.EXPECT().IsUpToDate(gomock.Any(), gomock.Any()).Return(false, nil)
},
setupMockSourcesChecker: func(m *MockSourcesCheckable) {
m.EXPECT().IsUpToDate(gomock.Any()).Return(false, nil)
},
expected: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
ctrl := gomock.NewController(t)
mockStatusChecker := NewMockStatusCheckable(ctrl)
if tt.setupMockStatusChecker != nil {
tt.setupMockStatusChecker(mockStatusChecker)
}
mockSourcesChecker := NewMockSourcesCheckable(ctrl)
if tt.setupMockSourcesChecker != nil {
tt.setupMockSourcesChecker(mockSourcesChecker)
}
result, err := IsTaskUpToDate(
context.Background(),
tt.task,
WithStatusChecker(mockStatusChecker),
WithSourcesChecker(mockSourcesChecker),
)
require.NoError(t, err)
assert.Equal(t, tt.expected, result)
})
}
}

View File

@ -1,23 +0,0 @@
package status
// None is a no-op Checker
type None struct{}
// IsUpToDate implements the Checker interface
func (None) IsUpToDate() (bool, error) {
return false, nil
}
// Value implements the Checker interface
func (None) Value() (interface{}, error) {
return "", nil
}
func (None) Kind() string {
return "none"
}
// OnError implements the Checker interface
func (None) OnError() error {
return nil
}

View File

@ -1,15 +0,0 @@
package status
var (
_ Checker = &Timestamp{}
_ Checker = &Checksum{}
_ Checker = None{}
)
// Checker is an interface that checks if the status is up-to-date
type Checker interface {
IsUpToDate() (bool, error)
Value() (interface{}, error)
OnError() error
Kind() string
}

View File

@ -4,6 +4,7 @@ import (
"context"
"errors"
"github.com/go-task/task/v3/internal/env"
"github.com/go-task/task/v3/internal/execext"
"github.com/go-task/task/v3/internal/logger"
"github.com/go-task/task/v3/taskfile"
@ -19,7 +20,7 @@ func (e *Executor) areTaskPreconditionsMet(ctx context.Context, t *taskfile.Task
err := execext.RunCommand(ctx, &execext.RunCommandOptions{
Command: p.Sh,
Dir: t.Dir,
Env: getEnviron(t),
Env: env.Get(t),
})
if err != nil {

128
status.go
View File

@ -4,20 +4,33 @@ import (
"context"
"fmt"
"github.com/go-task/task/v3/internal/execext"
"github.com/go-task/task/v3/internal/logger"
"github.com/go-task/task/v3/internal/status"
"github.com/go-task/task/v3/internal/fingerprint"
"github.com/go-task/task/v3/taskfile"
)
// Status returns an error if any the of given tasks is not up-to-date
func (e *Executor) Status(ctx context.Context, calls ...taskfile.Call) error {
for _, call := range calls {
// Compile the task
t, err := e.CompiledTask(call)
if err != nil {
return err
}
isUpToDate, err := e.isTaskUpToDate(ctx, t)
// Get the fingerprinting method to use
method := e.Taskfile.Method
if t.Method != "" {
method = t.Method
}
// Check if the task is up-to-date
isUpToDate, err := fingerprint.IsTaskUpToDate(ctx, t,
fingerprint.WithMethod(method),
fingerprint.WithTempDir(e.TempDir),
fingerprint.WithDry(e.Dry),
fingerprint.WithLogger(e.Logger),
)
if err != nil {
return err
}
@ -28,113 +41,14 @@ func (e *Executor) Status(ctx context.Context, calls ...taskfile.Call) error {
return nil
}
func (e *Executor) isTaskUpToDate(ctx context.Context, t *taskfile.Task) (bool, error) {
var statusUpToDate bool
var sourcesUpToDate bool
var err error
statusIsSet := len(t.Status) != 0
sourcesIsSet := len(t.Sources) != 0
// If status is set, check if it is up-to-date
if statusIsSet {
statusUpToDate, err = e.isTaskUpToDateStatus(ctx, t)
if err != nil {
return false, err
}
}
// If sources is set, check if they are up-to-date
if sourcesIsSet {
checker, err := e.getStatusChecker(t)
if err != nil {
return false, err
}
sourcesUpToDate, err = checker.IsUpToDate()
if err != nil {
return false, err
}
}
// If both status and sources are set, the task is up-to-date if both are up-to-date
if statusIsSet && sourcesIsSet {
return statusUpToDate && sourcesUpToDate, nil
}
// If only status is set, the task is up-to-date if the status is up-to-date
if statusIsSet {
return statusUpToDate, nil
}
// If only sources is set, the task is up-to-date if the sources are up-to-date
if sourcesIsSet {
return sourcesUpToDate, nil
}
// If no status or sources are set, the task should always run
// i.e. it is never considered "up-to-date"
return false, nil
}
func (e *Executor) statusOnError(t *taskfile.Task) error {
checker, err := e.getStatusChecker(t)
if err != nil {
return err
}
return checker.OnError()
}
func (e *Executor) getStatusChecker(t *taskfile.Task) (status.Checker, error) {
method := t.Method
if method == "" {
method = e.Taskfile.Method
}
switch method {
case "timestamp":
return e.timestampChecker(t), nil
case "checksum":
return e.checksumChecker(t), nil
case "none":
return status.None{}, nil
default:
return nil, fmt.Errorf(`task: invalid method "%s"`, method)
checker, err := fingerprint.NewSourcesChecker(method, e.TempDir, e.Dry)
if err != nil {
return err
}
}
func (e *Executor) timestampChecker(t *taskfile.Task) status.Checker {
return &status.Timestamp{
TempDir: e.TempDir,
Task: t.Name(),
Dir: t.Dir,
Sources: t.Sources,
Generates: t.Generates,
Dry: e.Dry,
}
}
func (e *Executor) checksumChecker(t *taskfile.Task) status.Checker {
return &status.Checksum{
TempDir: e.TempDir,
TaskDir: t.Dir,
Task: t.Name(),
Sources: t.Sources,
Generates: t.Generates,
Dry: e.Dry,
}
}
func (e *Executor) isTaskUpToDateStatus(ctx context.Context, t *taskfile.Task) (bool, error) {
for _, s := range t.Status {
err := execext.RunCommand(ctx, &execext.RunCommandOptions{
Command: s,
Dir: t.Dir,
Env: getEnviron(t),
})
if err != nil {
e.Logger.VerboseOutf(logger.Yellow, "task: status command %s exited non-zero: %s", s, err)
return false, nil
}
e.Logger.VerboseOutf(logger.Yellow, "task: status command %s exited zero", s)
}
return true, nil
return checker.OnError(t)
}

40
task.go
View File

@ -13,7 +13,9 @@ import (
"time"
"github.com/go-task/task/v3/internal/compiler"
"github.com/go-task/task/v3/internal/env"
"github.com/go-task/task/v3/internal/execext"
"github.com/go-task/task/v3/internal/fingerprint"
"github.com/go-task/task/v3/internal/logger"
"github.com/go-task/task/v3/internal/output"
"github.com/go-task/task/v3/internal/slicesext"
@ -157,7 +159,18 @@ func (e *Executor) RunTask(ctx context.Context, call taskfile.Call) error {
return err
}
upToDate, err := e.isTaskUpToDate(ctx, t)
// Get the fingerprinting method to use
method := e.Taskfile.Method
if t.Method != "" {
method = t.Method
}
upToDate, err := fingerprint.IsTaskUpToDate(ctx, t,
fingerprint.WithMethod(method),
fingerprint.WithTempDir(e.TempDir),
fingerprint.WithDry(e.Dry),
fingerprint.WithLogger(e.Logger),
)
if err != nil {
return err
}
@ -286,7 +299,7 @@ func (e *Executor) runCommand(ctx context.Context, t *taskfile.Task, call taskfi
err = execext.RunCommand(ctx, &execext.RunCommandOptions{
Command: cmd.Cmd,
Dir: t.Dir,
Env: getEnviron(t),
Env: env.Get(t),
PosixOpts: slicesext.UniqueJoin(e.Taskfile.Set, t.Set, cmd.Set),
BashOpts: slicesext.UniqueJoin(e.Taskfile.Shopt, t.Shopt, cmd.Shopt),
Stdin: e.Stdin,
@ -306,29 +319,6 @@ func (e *Executor) runCommand(ctx context.Context, t *taskfile.Task, call taskfi
}
}
func getEnviron(t *taskfile.Task) []string {
if t.Env == nil {
return nil
}
environ := os.Environ()
for k, v := range t.Env.ToCacheMap() {
str, isString := v.(string)
if !isString {
continue
}
if _, alreadySet := os.LookupEnv(k); alreadySet {
continue
}
environ = append(environ, fmt.Sprintf("%s=%s", k, str))
}
return environ
}
func (e *Executor) startExecution(ctx context.Context, t *taskfile.Task, execute func(ctx context.Context) error) error {
h, err := e.GetHash(t)
if err != nil {

View File

@ -8,7 +8,7 @@ import (
"github.com/go-task/task/v3/internal/execext"
"github.com/go-task/task/v3/internal/filepathext"
"github.com/go-task/task/v3/internal/status"
"github.com/go-task/task/v3/internal/fingerprint"
"github.com/go-task/task/v3/internal/templater"
"github.com/go-task/task/v3/taskfile"
)
@ -161,8 +161,11 @@ func (e *Executor) compiledTask(call taskfile.Call, evaluateShVars bool) (*taskf
}
if len(origTask.Status) > 0 {
for _, checker := range []status.Checker{e.timestampChecker(&new), e.checksumChecker(&new)} {
value, err := checker.Value()
timestampChecker := fingerprint.NewTimestampChecker(e.TempDir, e.Dry)
checksumChecker := fingerprint.NewChecksumChecker(e.TempDir, e.Dry)
for _, checker := range []fingerprint.SourcesCheckable{timestampChecker, checksumChecker} {
value, err := checker.Value(&new)
if err != nil {
return nil, err
}

View File

@ -12,8 +12,8 @@ import (
"github.com/radovskyb/watcher"
"github.com/go-task/task/v3/internal/fingerprint"
"github.com/go-task/task/v3/internal/logger"
"github.com/go-task/task/v3/internal/status"
"github.com/go-task/task/v3/taskfile"
)
@ -142,7 +142,7 @@ func (e *Executor) registerWatchedFiles(w *watcher.Watcher, calls ...taskfile.Ca
}
for _, s := range task.Sources {
files, err := status.Glob(task.Dir, s)
files, err := fingerprint.Glob(task.Dir, s)
if err != nil {
return fmt.Errorf("task: %s: %w", s, err)
}