mirror of
https://github.com/go-task/task.git
synced 2025-04-23 12:18:57 +02:00
add checksum based status check, alternative to timestamp based
This commit is contained in:
parent
95f7b9443f
commit
c295a1998a
19
README.md
19
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
|
necessary to run the task. If not, it will just print a message like
|
||||||
`Task "js" is up to date`.
|
`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
|
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:
|
is returned (exit status 0), the task is considered up-to-date:
|
||||||
|
|
||||||
|
@ -6,3 +6,4 @@ GO_PACKAGES:
|
|||||||
./args
|
./args
|
||||||
./cmd/task
|
./cmd/task
|
||||||
./execext
|
./execext
|
||||||
|
./status
|
||||||
|
133
status.go
133
status.go
@ -2,19 +2,52 @@ package task
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"os"
|
"fmt"
|
||||||
"path/filepath"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/go-task/task/execext"
|
"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) {
|
func (t *Task) isUpToDate(ctx context.Context) (bool, error) {
|
||||||
if len(t.Status) > 0 {
|
if len(t.Status) > 0 {
|
||||||
return t.isUpToDateStatus(ctx)
|
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) {
|
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
|
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
|
|
||||||
}
|
|
||||||
|
64
status/checksum.go
Normal file
64
status/checksum.go
Normal file
@ -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))
|
||||||
|
}
|
23
status/glob.go
Normal file
23
status/glob.go
Normal file
@ -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
|
||||||
|
}
|
14
status/none.go
Normal file
14
status/none.go
Normal file
@ -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
|
||||||
|
}
|
13
status/status.go
Normal file
13
status/status.go
Normal file
@ -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
|
||||||
|
}
|
85
status/timestamp.go
Normal file
85
status/timestamp.go
Normal file
@ -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
|
||||||
|
}
|
9
task.go
9
task.go
@ -49,6 +49,7 @@ type Tasks map[string]*Task
|
|||||||
|
|
||||||
// Task represents a task
|
// Task represents a task
|
||||||
type Task struct {
|
type Task struct {
|
||||||
|
Task string
|
||||||
Cmds []*Cmd
|
Cmds []*Cmd
|
||||||
Deps []*Dep
|
Deps []*Dep
|
||||||
Desc string
|
Desc string
|
||||||
@ -60,6 +61,7 @@ type Task struct {
|
|||||||
Set string
|
Set string
|
||||||
Env Vars
|
Env Vars
|
||||||
Silent bool
|
Silent bool
|
||||||
|
Method string
|
||||||
}
|
}
|
||||||
|
|
||||||
// Run runs Task
|
// Run runs Task
|
||||||
@ -135,14 +137,17 @@ func (e *Executor) RunTask(ctx context.Context, call Call) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if upToDate {
|
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
|
return nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
for i := range t.Cmds {
|
for i := range t.Cmds {
|
||||||
if err := e.runCommand(ctx, t, call, i); err != nil {
|
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
|
return nil
|
||||||
|
34
task_test.go
34
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) {
|
func TestInit(t *testing.T) {
|
||||||
const dir = "testdata/init"
|
const dir = "testdata/init"
|
||||||
var file = filepath.Join(dir, "Taskfile.yml")
|
var file = filepath.Join(dir, "Taskfile.yml")
|
||||||
|
@ -31,6 +31,10 @@ func (e *Executor) ReadTaskfile() error {
|
|||||||
if err := mergo.MapWithOverwrite(&e.Tasks, osTasks); err != nil {
|
if err := mergo.MapWithOverwrite(&e.Tasks, osTasks); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
for name, task := range e.Tasks {
|
||||||
|
task.Task = name
|
||||||
|
}
|
||||||
|
|
||||||
return e.readTaskvars()
|
return e.readTaskvars()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
2
testdata/checksum/.gitignore
vendored
Normal file
2
testdata/checksum/.gitignore
vendored
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
.task/
|
||||||
|
generated.txt
|
8
testdata/checksum/Taskfile.yml
vendored
Normal file
8
testdata/checksum/Taskfile.yml
vendored
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
build:
|
||||||
|
cmds:
|
||||||
|
- cp ./source.txt ./generated.txt
|
||||||
|
sources:
|
||||||
|
- ./source.txt
|
||||||
|
generates:
|
||||||
|
- ./generated.txt
|
||||||
|
method: checksum
|
1
testdata/checksum/source.txt
vendored
Normal file
1
testdata/checksum/source.txt
vendored
Normal file
@ -0,0 +1 @@
|
|||||||
|
Hello, World!
|
@ -210,6 +210,7 @@ func (e *Executor) CompiledTask(call Call) (*Task, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
new := Task{
|
new := Task{
|
||||||
|
Task: origTask.Task,
|
||||||
Desc: r.replace(origTask.Desc),
|
Desc: r.replace(origTask.Desc),
|
||||||
Sources: r.replaceSlice(origTask.Sources),
|
Sources: r.replaceSlice(origTask.Sources),
|
||||||
Generates: r.replaceSlice(origTask.Generates),
|
Generates: r.replaceSlice(origTask.Generates),
|
||||||
@ -219,6 +220,7 @@ func (e *Executor) CompiledTask(call Call) (*Task, error) {
|
|||||||
Set: r.replace(origTask.Set),
|
Set: r.replace(origTask.Set),
|
||||||
Env: r.replaceVars(origTask.Env),
|
Env: r.replaceVars(origTask.Env),
|
||||||
Silent: origTask.Silent,
|
Silent: origTask.Silent,
|
||||||
|
Method: r.replace(origTask.Method),
|
||||||
}
|
}
|
||||||
if e.Dir != "" && !filepath.IsAbs(new.Dir) {
|
if e.Dir != "" && !filepath.IsAbs(new.Dir) {
|
||||||
new.Dir = filepath.Join(e.Dir, new.Dir)
|
new.Dir = filepath.Join(e.Dir, new.Dir)
|
||||||
|
Loading…
x
Reference in New Issue
Block a user