diff --git a/README.md b/README.md index d1a9a4c5..59ff8a70 100644 --- a/README.md +++ b/README.md @@ -244,6 +244,25 @@ Task will compare the modification date/time of the files to determine if it's necessary to run the task. If not, it will just print a message like `Task "js" is up to date`. +If you prefer this check to be made by the content of the files, instead of +its timestamp, just set the `method` property to `checksum`. +You will probably want to ignore the `.task` folder in your `.gitignore` file +(It's there that Task stores the last checksum). +This feature is still experimental and can change until it's stable. + +```yml +build: + cmds: + - go build . + sources: + - ./*.go + generates: + - app{{exeExt}} + method: checksum +``` + +> TIP: method `none` skips any validation and always run the task. + Alternatively, you can inform a sequence of tests as `status`. If no error is returned (exit status 0), the task is considered up-to-date: diff --git a/Taskvars.yml b/Taskvars.yml index 41fa65b3..947ac050 100644 --- a/Taskvars.yml +++ b/Taskvars.yml @@ -6,3 +6,4 @@ GO_PACKAGES: ./args ./cmd/task ./execext + ./status diff --git a/status.go b/status.go index a21b11e9..bd564071 100644 --- a/status.go +++ b/status.go @@ -2,19 +2,52 @@ package task import ( "context" - "os" - "path/filepath" - "time" + "fmt" "github.com/go-task/task/execext" - "github.com/mattn/go-zglob" + "github.com/go-task/task/status" ) func (t *Task) isUpToDate(ctx context.Context) (bool, error) { if len(t.Status) > 0 { return t.isUpToDateStatus(ctx) } - return t.isUpToDateTimestamp(ctx) + + checker, err := t.getStatusChecker() + if err != nil { + return false, err + } + + return checker.IsUpToDate() +} + +func (t *Task) statusOnError() error { + checker, err := t.getStatusChecker() + if err != nil { + return err + } + return checker.OnError() +} + +func (t *Task) getStatusChecker() (status.Checker, error) { + switch t.Method { + case "", "timestamp": + return &status.Timestamp{ + Dir: t.Dir, + Sources: t.Sources, + Generates: t.Generates, + }, nil + case "checksum": + return &status.Checksum{ + Dir: t.Dir, + Task: t.Task, + Sources: t.Sources, + }, nil + case "none": + return status.None{}, nil + default: + return nil, fmt.Errorf(`task: invalid method "%s"`, t.Method) + } } func (t *Task) isUpToDateStatus(ctx context.Context) (bool, error) { @@ -31,93 +64,3 @@ func (t *Task) isUpToDateStatus(ctx context.Context) (bool, error) { } return true, nil } - -func (t *Task) isUpToDateTimestamp(ctx context.Context) (bool, error) { - if len(t.Sources) == 0 || len(t.Generates) == 0 { - return false, nil - } - - sourcesMaxTime, err := getPatternsMaxTime(t.Dir, t.Sources) - if err != nil || sourcesMaxTime.IsZero() { - return false, nil - } - - generatesMinTime, err := getPatternsMinTime(t.Dir, t.Generates) - if err != nil || generatesMinTime.IsZero() { - return false, nil - } - return !generatesMinTime.Before(sourcesMaxTime), nil -} - -func getPatternsMinTime(dir string, patterns []string) (m time.Time, err error) { - for _, p := range patterns { - if !filepath.IsAbs(p) { - p = filepath.Join(dir, p) - } - mp, err := getPatternMinTime(p) - if err != nil { - return time.Time{}, err - } - m = minTime(m, mp) - } - return -} -func getPatternsMaxTime(dir string, patterns []string) (m time.Time, err error) { - for _, p := range patterns { - if !filepath.IsAbs(p) { - p = filepath.Join(dir, p) - } - mp, err := getPatternMaxTime(p) - if err != nil { - return time.Time{}, err - } - m = maxTime(m, mp) - } - return -} - -func getPatternMinTime(pattern string) (m time.Time, err error) { - files, err := zglob.Glob(pattern) - if err != nil { - return time.Time{}, err - } - - for _, f := range files { - info, err := os.Stat(f) - if err != nil { - return time.Time{}, err - } - m = minTime(m, info.ModTime()) - } - return -} - -func getPatternMaxTime(pattern string) (m time.Time, err error) { - files, err := zglob.Glob(pattern) - if err != nil { - return time.Time{}, err - } - - for _, f := range files { - info, err := os.Stat(f) - if err != nil { - return time.Time{}, err - } - m = maxTime(m, info.ModTime()) - } - return -} - -func minTime(a, b time.Time) time.Time { - if !a.IsZero() && a.Before(b) { - return a - } - return b -} - -func maxTime(a, b time.Time) time.Time { - if a.After(b) { - return a - } - return b -} diff --git a/status/checksum.go b/status/checksum.go new file mode 100644 index 00000000..d0a95383 --- /dev/null +++ b/status/checksum.go @@ -0,0 +1,64 @@ +package status + +import ( + "crypto/md5" + "fmt" + "io" + "io/ioutil" + "os" + "path/filepath" + "strings" +) + +// Checksum validades if a task is up to date by calculating its source +// files checksum +type Checksum struct { + Dir string + Task string + Sources []string +} + +// IsUpToDate implements the Checker interface +func (c *Checksum) IsUpToDate() (bool, error) { + checksumFile := filepath.Join(c.Dir, ".task", c.Task) + + data, _ := ioutil.ReadFile(checksumFile) + oldMd5 := strings.TrimSpace(string(data)) + + sources, err := glob(c.Dir, c.Sources) + if err != nil { + return false, err + } + + newMd5, err := c.checksum(sources...) + if err != nil { + return false, nil + } + + _ = os.MkdirAll(filepath.Join(c.Dir, ".task"), 0755) + if err = ioutil.WriteFile(checksumFile, []byte(newMd5), 0644); err != nil { + return false, err + } + return oldMd5 == newMd5, nil +} + +func (c *Checksum) checksum(files ...string) (string, error) { + h := md5.New() + + for _, f := range files { + f, err := os.Open(f) + if err != nil { + return "", err + } + if _, err := io.Copy(h, f); err != nil { + return "", err + } + } + + return fmt.Sprintf("%x", h.Sum(nil)), nil +} + +// OnError implements the Checker interface +func (c *Checksum) OnError() error { + return os.Remove(filepath.Join(c.Dir, ".task", c.Task)) +} diff --git a/status/glob.go b/status/glob.go new file mode 100644 index 00000000..04ffe6d2 --- /dev/null +++ b/status/glob.go @@ -0,0 +1,23 @@ +package status + +import ( + "path/filepath" + "sort" + + "github.com/mattn/go-zglob" +) + +func glob(dir string, globs []string) (files []string, err error) { + for _, g := range globs { + if !filepath.IsAbs(g) { + g = filepath.Join(dir, g) + } + f, err := zglob.Glob(g) + if err != nil { + return nil, err + } + files = append(files, f...) + } + sort.Strings(files) + return +} diff --git a/status/none.go b/status/none.go new file mode 100644 index 00000000..01e35060 --- /dev/null +++ b/status/none.go @@ -0,0 +1,14 @@ +package status + +// None is a no-op Checker +type None struct{} + +// IsUpToDate implements the Checker interface +func (None) IsUpToDate() (bool, error) { + return false, nil +} + +// OnError implements the Checker interface +func (None) OnError() error { + return nil +} diff --git a/status/status.go b/status/status.go new file mode 100644 index 00000000..320ca8a6 --- /dev/null +++ b/status/status.go @@ -0,0 +1,13 @@ +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) + OnError() error +} diff --git a/status/timestamp.go b/status/timestamp.go new file mode 100644 index 00000000..62b9aafb --- /dev/null +++ b/status/timestamp.go @@ -0,0 +1,85 @@ +package status + +import ( + "os" + "time" +) + +// Timestamp checks if any source change compared with the generated files, +// using file modifications timestamps. +type Timestamp struct { + Dir string + Sources []string + Generates []string +} + +// IsUpToDate implements the Checker interface +func (t *Timestamp) IsUpToDate() (bool, error) { + if len(t.Sources) == 0 || len(t.Generates) == 0 { + return false, nil + } + + sources, err := glob(t.Dir, t.Sources) + if err != nil { + return false, nil + } + generates, err := glob(t.Dir, t.Generates) + if err != nil { + return false, nil + } + + sourcesMaxTime, err := getMaxTime(sources...) + if err != nil || sourcesMaxTime.IsZero() { + return false, nil + } + + generatesMinTime, err := getMinTime(generates...) + if err != nil || generatesMinTime.IsZero() { + return false, nil + } + + return !generatesMinTime.Before(sourcesMaxTime), nil +} + +func getMinTime(files ...string) (time.Time, error) { + var t time.Time + for _, f := range files { + info, err := os.Stat(f) + if err != nil { + return time.Time{}, err + } + t = minTime(t, info.ModTime()) + } + return t, nil +} + +func getMaxTime(files ...string) (time.Time, error) { + var t time.Time + for _, f := range files { + info, err := os.Stat(f) + if err != nil { + return time.Time{}, err + } + t = maxTime(t, info.ModTime()) + } + return t, nil +} + +func minTime(a, b time.Time) time.Time { + if !a.IsZero() && a.Before(b) { + return a + } + return b +} + +func maxTime(a, b time.Time) time.Time { + if a.After(b) { + return a + } + return b +} + +// OnError implements the Checker interface +func (*Timestamp) OnError() error { + return nil +} diff --git a/task.go b/task.go index bbf49397..297ec2b4 100644 --- a/task.go +++ b/task.go @@ -49,6 +49,7 @@ type Tasks map[string]*Task // Task represents a task type Task struct { + Task string Cmds []*Cmd Deps []*Dep Desc string @@ -60,6 +61,7 @@ type Task struct { Set string Env Vars Silent bool + Method string } // Run runs Task @@ -135,14 +137,17 @@ func (e *Executor) RunTask(ctx context.Context, call Call) error { return err } if upToDate { - e.printfln(`task: Task "%s" is up to date`, call.Task) + e.printfln(`task: Task "%s" is up to date`, t.Task) return nil } } for i := range t.Cmds { if err := e.runCommand(ctx, t, call, i); err != nil { - return &taskRunError{call.Task, err} + if err2 := t.statusOnError(); err2 != nil { + e.verbosePrintfln("task: error cleaning status on error: %v", err2) + } + return &taskRunError{t.Task, err} } } return nil diff --git a/task_test.go b/task_test.go index 071ea8c1..f6da0cea 100644 --- a/task_test.go +++ b/task_test.go @@ -312,6 +312,40 @@ func TestGenerates(t *testing.T) { } } +func TestStatusChecksum(t *testing.T) { + const dir = "testdata/checksum" + + files := []string{ + "generated.txt", + ".task/build", + } + + for _, f := range files { + _ = os.Remove(filepath.Join(dir, f)) + + _, err := os.Stat(filepath.Join(dir, f)) + assert.Error(t, err) + } + + var buff bytes.Buffer + e := task.Executor{ + Dir: dir, + Stdout: &buff, + Stderr: &buff, + } + assert.NoError(t, e.ReadTaskfile()) + + assert.NoError(t, e.Run(task.Call{Task: "build"})) + for _, f := range files { + _, err := os.Stat(filepath.Join(dir, f)) + assert.NoError(t, err) + } + + buff.Reset() + assert.NoError(t, e.Run(task.Call{Task: "build"})) + assert.Equal(t, `task: Task "build" is up to date`+"\n", buff.String()) +} + func TestInit(t *testing.T) { const dir = "testdata/init" var file = filepath.Join(dir, "Taskfile.yml") diff --git a/taskfile.go b/taskfile.go index 56aec576..01c553ba 100644 --- a/taskfile.go +++ b/taskfile.go @@ -31,6 +31,10 @@ func (e *Executor) ReadTaskfile() error { if err := mergo.MapWithOverwrite(&e.Tasks, osTasks); err != nil { return err } + for name, task := range e.Tasks { + task.Task = name + } + return e.readTaskvars() } diff --git a/testdata/checksum/.gitignore b/testdata/checksum/.gitignore new file mode 100644 index 00000000..999957d0 --- /dev/null +++ b/testdata/checksum/.gitignore @@ -0,0 +1,2 @@ +.task/ +generated.txt diff --git a/testdata/checksum/Taskfile.yml b/testdata/checksum/Taskfile.yml new file mode 100644 index 00000000..aaa1b1f4 --- /dev/null +++ b/testdata/checksum/Taskfile.yml @@ -0,0 +1,8 @@ +build: + cmds: + - cp ./source.txt ./generated.txt + sources: + - ./source.txt + generates: + - ./generated.txt + method: checksum diff --git a/testdata/checksum/source.txt b/testdata/checksum/source.txt new file mode 100644 index 00000000..8ab686ea --- /dev/null +++ b/testdata/checksum/source.txt @@ -0,0 +1 @@ +Hello, World! diff --git a/variables.go b/variables.go index 14b5a8bf..c60172e1 100644 --- a/variables.go +++ b/variables.go @@ -210,6 +210,7 @@ func (e *Executor) CompiledTask(call Call) (*Task, error) { } new := Task{ + Task: origTask.Task, Desc: r.replace(origTask.Desc), Sources: r.replaceSlice(origTask.Sources), Generates: r.replaceSlice(origTask.Generates), @@ -219,6 +220,7 @@ func (e *Executor) CompiledTask(call Call) (*Task, error) { Set: r.replace(origTask.Set), Env: r.replaceVars(origTask.Env), Silent: origTask.Silent, + Method: r.replace(origTask.Method), } if e.Dir != "" && !filepath.IsAbs(new.Dir) { new.Dir = filepath.Join(e.Dir, new.Dir)