Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 60 additions & 0 deletions internal/fingerprint/fingerprint_key.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
package fingerprint

import (
"encoding/json"
"fmt"

"github.com/zeebo/xxh3"

"github.com/go-task/task/v3/taskfile/ast"
)

type fingerprintIdentity struct {
Task string `json:"task"`
Dir string `json:"dir"`
Sources []string `json:"sources,omitempty"`
Generates []string `json:"generates,omitempty"`
}

func taskFingerprintKey(t *ast.Task) string {
name := taskIdentityName(t)
identity := fingerprintIdentity{
Task: name,
Dir: t.Dir,
Sources: globPatterns(t.Sources),
Generates: globPatterns(t.Generates),
}

encoded, err := json.Marshal(identity)
if err != nil {
return normalizeFilename(name)
}

return normalizeFilename(fmt.Sprintf("%s-%x", name, xxh3.Hash(encoded)))
}

func taskIdentityName(t *ast.Task) string {
if t.FullName != "" {
return t.FullName
}
return t.Task
}

func globPatterns(globs []*ast.Glob) []string {
if len(globs) == 0 {
return nil
}

patterns := make([]string, 0, len(globs))
for _, glob := range globs {
if glob == nil {
continue
}
if glob.Negate {
patterns = append(patterns, "!"+glob.Glob)
continue
}
patterns = append(patterns, glob.Glob)
}
return patterns
}
2 changes: 1 addition & 1 deletion internal/fingerprint/sources_checksum.go
Original file line number Diff line number Diff line change
Expand Up @@ -115,7 +115,7 @@ func (c *ChecksumChecker) checksum(t *ast.Task) (string, error) {
}

func (checker *ChecksumChecker) checksumFilePath(t *ast.Task) string {
return filepath.Join(checker.tempDir, "checksum", normalizeFilename(t.Name()))
return filepath.Join(checker.tempDir, "checksum", taskFingerprintKey(t))
}

var checksumFilenameRegexp = regexp.MustCompile("[^A-z0-9]")
Expand Down
2 changes: 1 addition & 1 deletion internal/fingerprint/sources_timestamp.go
Original file line number Diff line number Diff line change
Expand Up @@ -147,5 +147,5 @@ func (*TimestampChecker) OnError(t *ast.Task) error {
}

func (checker *TimestampChecker) timestampFilePath(t *ast.Task) string {
return filepath.Join(checker.tempDir, "timestamp", normalizeFilename(t.Task))
return filepath.Join(checker.tempDir, "timestamp", taskFingerprintKey(t))
}
83 changes: 83 additions & 0 deletions task_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -487,6 +487,89 @@ func TestStatusChecksum(t *testing.T) { // nolint:paralleltest // cannot run in
}
}

func TestLoopedSubtasksUseInvocationSpecificFingerprints(t *testing.T) {
t.Parallel()

tests := []struct {
name string
method string
}{
{name: "checksum", method: "checksum"},
{name: "timestamp", method: "timestamp"},
}

for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
t.Parallel()

dir := t.TempDir()
taskfilePath := filepathext.SmartJoin(dir, "Taskfile.yml")
runsPath := filepathext.SmartJoin(dir, ".runs")

taskfile := fmt.Sprintf(`
version: '3'

tasks:
copy:
method: %s
sources:
- '*.in'
generates:
- '*.out'
cmds:
- for: sources
task: copy:single
vars:
SOURCE: "{{.ITEM}}"
TARGET: '{{.ITEM | replace ".in" ".out"}}'

copy:single:
method: %s
sources:
- '{{.SOURCE}}'
generates:
- '{{.TARGET}}'
cmds:
- cp "{{.SOURCE}}" "{{.TARGET}}"
- echo "{{.SOURCE}}" >> .runs
`, test.method, test.method)

require.NoError(t, os.WriteFile(taskfilePath, []byte(strings.TrimSpace(taskfile)+"\n"), 0o644))
require.NoError(t, os.WriteFile(filepathext.SmartJoin(dir, "1.in"), []byte("1.1\n"), 0o644))
require.NoError(t, os.WriteFile(filepathext.SmartJoin(dir, "2.in"), []byte("2.2\n"), 0o644))
require.NoError(t, os.WriteFile(filepathext.SmartJoin(dir, "3.in"), []byte("3.3\n"), 0o644))

var buff bytes.Buffer
e := task.NewExecutor(
task.WithDir(dir),
task.WithEntrypoint(taskfilePath),
task.WithTempDir(task.TempDir{
Remote: filepathext.SmartJoin(dir, ".task"),
Fingerprint: filepathext.SmartJoin(dir, ".task"),
}),
task.WithStdout(&buff),
task.WithStderr(&buff),
)
require.NoError(t, e.Setup())
require.NoError(t, e.Run(t.Context(), &task.Call{Task: "copy"}))

firstRun, err := os.ReadFile(runsPath)
require.NoError(t, err)
assert.Equal(t, []string{"1.in", "2.in", "3.in"}, strings.Fields(string(firstRun)))

require.NoError(t, os.WriteFile(runsPath, nil, 0o644))
require.NoError(t, os.WriteFile(filepathext.SmartJoin(dir, "2.in"), []byte("2.3\n"), 0o644))

buff.Reset()
require.NoError(t, e.Run(t.Context(), &task.Call{Task: "copy"}))

secondRun, err := os.ReadFile(runsPath)
require.NoError(t, err)
assert.Equal(t, []string{"2.in"}, strings.Fields(string(secondRun)))
})
}
}

func TestStatusVariables(t *testing.T) {
t.Parallel()

Expand Down
70 changes: 48 additions & 22 deletions watch.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,6 @@ import (
"github.com/puzpuzpuz/xsync/v4"

"github.com/go-task/task/v3/errors"
"github.com/go-task/task/v3/internal/filepathext"
"github.com/go-task/task/v3/internal/fingerprint"
"github.com/go-task/task/v3/internal/fsnotifyext"
"github.com/go-task/task/v3/internal/logger"
Expand Down Expand Up @@ -68,6 +67,12 @@ func (e *Executor) watchTasks(calls ...*Call) error {

closeOnInterrupt(w)

previousSources, err := e.collectSources(calls)
if err != nil {
cancel()
return err
}

go func() {
for {
select {
Expand All @@ -78,34 +83,43 @@ func (e *Executor) watchTasks(calls ...*Call) error {
}
e.Logger.VerboseErrf(logger.Magenta, "task: received watch event: %v\n", event)

if ShouldIgnore(event.Name) {
e.Logger.VerboseErrf(logger.Magenta, "task: event skipped for being an ignored dir: %s\n", event.Name)
continue
}

currentSources, err := e.collectSources(calls)
if err != nil {
e.Logger.Errf(logger.Red, "%v\n", err)
continue
}

// Prevent cancellation of any in-progress task if the event is not relevant to any of the task sources.
// This could be fairly common if the task is generating files in the same directory as its sources.
if !IsWatchEventRelevant(event, currentSources, previousSources) {
relPath := event.Name
if rel, err := filepath.Rel(e.Dir, event.Name); err == nil {
relPath = rel
}
e.Logger.VerboseErrf(logger.Magenta, "task: skipped for file not in sources: %s\n", relPath)
previousSources = currentSources
continue
}

previousSources = currentSources
// Cancel any in-progress task to avoid multiple concurrent runs of the same task, which could cause issues if they are writing to the same files.
cancel()
ctx, cancel = context.WithCancel(context.Background())

e.Compiler.ResetCache()

for _, c := range calls {
go func() {
if ShouldIgnore(event.Name) {
e.Logger.VerboseErrf(logger.Magenta, "task: event skipped for being an ignored dir: %s\n", event.Name)
return
}
t, err := e.GetTask(c)
if err != nil {
e.Logger.Errf(logger.Red, "%v\n", err)
return
}
baseDir := filepathext.SmartJoin(e.Dir, t.Dir)
files, err := e.collectSources(calls)
if err != nil {
e.Logger.Errf(logger.Red, "%v\n", err)
return
}

if !event.Has(fsnotify.Remove) && !slices.Contains(files, event.Name) {
relPath, _ := filepath.Rel(baseDir, event.Name)
e.Logger.VerboseErrf(logger.Magenta, "task: skipped for file not in sources: %s\n", relPath)
return
}
// We could attempt to check if the event is relevant to a specific Calls sources. However,
// we would also need to track the previous sources for each call, which would add complexity
// and potential edge cases around tracking sources for tasks with dynamic sources. For
// simplicity, we will just re-run all calls on any relevant event and rely on the fingerprinting
// to skip work if the sources are not actually changed.
err = e.RunTask(ctx, c)
if err == nil {
e.Logger.Errf(logger.Green, "task: task \"%s\" finished running\n", c.Task)
Expand Down Expand Up @@ -144,6 +158,18 @@ func (e *Executor) watchTasks(calls ...*Call) error {
return nil
}

func IsWatchEventRelevant(event fsnotify.Event, currentSources []string, previousSources []string) bool {
if slices.Contains(currentSources, event.Name) {
return true
}

if event.Has(fsnotify.Remove) || event.Has(fsnotify.Rename) {
return slices.Contains(previousSources, event.Name)
}

return false
}

func isContextError(err error) bool {
if taskRunErr, ok := err.(*errors.TaskRunError); ok {
err = taskRunErr.Err
Expand Down
53 changes: 53 additions & 0 deletions watch_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import (
"testing"
"time"

"github.com/fsnotify/fsnotify"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"

Expand Down Expand Up @@ -100,3 +101,55 @@ func TestShouldIgnore(t *testing.T) {
})
}
}

func TestIsWatchEventRelevant(t *testing.T) {
t.Parallel()

const (
sourceFile = "/tmp/example/a.in"
anotherFile = "/tmp/example/b.in"
derivedFile = "/tmp/example/a.out"
removedFile = "/tmp/example/removed.in"
unrelatedDir = "/tmp/example"
)

t.Run("source modify is relevant", func(t *testing.T) {
t.Parallel()
relevant := task.IsWatchEventRelevant(
fsnotify.Event{Name: sourceFile, Op: fsnotify.Write},
[]string{sourceFile, anotherFile},
[]string{sourceFile, anotherFile},
)
require.True(t, relevant)
})

t.Run("non-source create is not relevant", func(t *testing.T) {
t.Parallel()
relevant := task.IsWatchEventRelevant(
fsnotify.Event{Name: derivedFile, Op: fsnotify.Create},
[]string{sourceFile, anotherFile},
[]string{sourceFile, anotherFile},
)
require.False(t, relevant)
})

t.Run("removed source remains relevant using previous snapshot", func(t *testing.T) {
t.Parallel()
relevant := task.IsWatchEventRelevant(
fsnotify.Event{Name: removedFile, Op: fsnotify.Remove},
[]string{sourceFile, anotherFile},
[]string{sourceFile, anotherFile, removedFile},
)
require.True(t, relevant)
})

t.Run("removed non-source is not relevant", func(t *testing.T) {
t.Parallel()
relevant := task.IsWatchEventRelevant(
fsnotify.Event{Name: unrelatedDir, Op: fsnotify.Remove},
[]string{sourceFile, anotherFile},
[]string{sourceFile, anotherFile},
)
require.False(t, relevant)
})
}