From d65f329445a76584d9012bc55b88d4454a195f98 Mon Sep 17 00:00:00 2001 From: Matt Keeler Date: Sun, 15 Mar 2026 22:41:41 +0100 Subject: [PATCH 1/2] Watcher does not ignore created files that do not match sources. Co-authored-by: Oleg Butuzov Co-authored-by: Timothy Rule <34501912+trulede@users.noreply.github.com> --- testdata/watch/sources/Taskfile.yaml | 8 ++ watch.go | 107 ++++++++++++++++----- watch_test.go | 135 +++++++++++++++++++++++++++ 3 files changed, 226 insertions(+), 24 deletions(-) create mode 100644 testdata/watch/sources/Taskfile.yaml diff --git a/testdata/watch/sources/Taskfile.yaml b/testdata/watch/sources/Taskfile.yaml new file mode 100644 index 0000000000..918c0f8e06 --- /dev/null +++ b/testdata/watch/sources/Taskfile.yaml @@ -0,0 +1,8 @@ +version: '3' + +tasks: + default: + sources: + - "./**/*.txt" + cmds: + - echo "Task running!" diff --git a/watch.go b/watch.go index 8e7f7ccf7d..b39c8f3859 100644 --- a/watch.go +++ b/watch.go @@ -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" @@ -25,6 +24,8 @@ import ( const defaultWaitTime = 100 * time.Millisecond +var refreshChan = make(chan string) + // watchTasks start watching the given tasks func (e *Executor) watchTasks(calls ...*Call) error { tasks := make([]string, len(calls)) @@ -68,9 +69,24 @@ func (e *Executor) watchTasks(calls ...*Call) error { closeOnInterrupt(w) + watchFiles, err := e.collectSources(calls) + if err != nil { + cancel() + return err + } go func() { for { select { + case path := <-refreshChan: + // If a path is added its necessary to refresh the sources, otherwise the + // watcher may not pick up any changes in that new path. + _ = path + watchFiles, err = e.collectSources(calls) + if err != nil { + e.Logger.Errf(logger.Red, "%v\n", err) + continue + } + case event, ok := <-eventsChan: if !ok { cancel() @@ -78,34 +94,57 @@ func (e *Executor) watchTasks(calls ...*Call) error { } e.Logger.VerboseErrf(logger.Magenta, "task: received watch event: %v\n", event) - 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 + // Check if this watch event should be ignored. + if ShouldIgnore(event.Name) { + e.Logger.VerboseErrf(logger.Magenta, "task: event skipped for being an ignored dir: %s\n", event.Name) + continue + } + if event.Has(fsnotify.Remove) || event.Has(fsnotify.Rename) || event.Has(fsnotify.Write) { + if !slices.Contains(watchFiles, event.Name) { + relPath := event.Name + if rel, err := filepath.Rel(e.Dir, event.Name); err == nil { + relPath = rel } - t, err := e.GetTask(c) - if err != nil { - e.Logger.Errf(logger.Red, "%v\n", err) - return + e.Logger.VerboseErrf(logger.Magenta, "task: skipped for file not in sources: %s\n", relPath) + continue + } + } + if event.Has(fsnotify.Create) { + createDir := false + if info, err := os.Stat(event.Name); err == nil { + if info.IsDir() { + createDir = true } - baseDir := filepathext.SmartJoin(e.Dir, t.Dir) - files, err := e.collectSources(calls) - if err != nil { + } + watchFiles, err = e.collectSources(calls) + if err != nil { + e.Logger.Errf(logger.Red, "%v\n", err) + continue + } + + if createDir { + // If the CREATE relates to a folder, update the registered watch dirs (immediately). + if err := e.registerWatchedDirs(w, calls...); 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) + } else { + if !slices.Contains(watchFiles, event.Name) { + 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) - return + continue } + } + } + + // The watch event is good, restart the task calls. + cancel() + ctx, cancel = context.WithCancel(context.Background()) + e.Compiler.ResetCache() + for _, c := range calls { + go func() { err = e.RunTask(ctx, c) if err == nil { e.Logger.Errf(logger.Green, "task: task \"%s\" finished running\n", c.Task) @@ -167,8 +206,25 @@ func (e *Executor) registerWatchedDirs(w *fsnotify.Watcher, calls ...*Call) erro if err != nil { return err } + dirs := []string{} for _, f := range files { - d := filepath.Dir(f) + dir := filepath.Dir(f) + if !slices.Contains(dirs, dir) { + dirs = append(dirs, dir) + } + } + + // Remove dirs from the watch, otherwise the watched dir may become stale and + // if the dir is recreated, it will not trigger any watch events. + e.watchedDirs.Range(func(dir string, value bool) bool { + if !slices.Contains(dirs, dir) { + e.watchedDirs.Delete(dir) + } + return true + }) + + // Add new dirs to the watch. + for _, d := range dirs { if isSet, ok := e.watchedDirs.Load(d); ok && isSet { continue } @@ -181,6 +237,9 @@ func (e *Executor) registerWatchedDirs(w *fsnotify.Watcher, calls ...*Call) erro e.watchedDirs.Store(d, true) relPath, _ := filepath.Rel(e.Dir, d) e.Logger.VerboseOutf(logger.Green, "task: watching new dir: %v\n", relPath) + + // Signal that the watcher should refresh its watch file list. + refreshChan <- d } return nil } diff --git a/watch_test.go b/watch_test.go index 8bef86ed49..ac7c8211c2 100644 --- a/watch_test.go +++ b/watch_test.go @@ -8,6 +8,8 @@ import ( "context" "fmt" "os" + "path/filepath" + "slices" "strings" "testing" "time" @@ -100,3 +102,136 @@ func TestShouldIgnore(t *testing.T) { }) } } + +// Create, Remove, Rename, Write +// In sources, not in sources +// sources is a ./**/*.txt + +func TestWatchSources(t *testing.T) { + t.Parallel() + + tests := []struct { + action string + path string + expectRestart bool + }{ + // Entry condition: file fubar/foo.txt exists. + {"create", "fubar/bar.txt", true}, + {"remove", "fubar/foo.txt", true}, + {"rename", "fubar/foo.txt", true}, + {"write", "fubar/foo.txt", true}, + {"create", "fubar/bar.text", false}, + {"remove", "fubar/foo.text", false}, + {"rename", "fubar/foo.text", false}, + {"write", "fubar/foo.text", false}, + } + + for _, tc := range tests { + tc := tc + t.Run(fmt.Sprintf("%s-%s", tc.action, tc.path), func(t *testing.T) { + t.Parallel() + + checks := []string{`Started watching for tasks: default`, `echo "Task running!"`} + + // Setup the watch dir. + tmpDir := t.TempDir() + data, _ := os.ReadFile("testdata/watch/sources/Taskfile.yaml") + os.WriteFile(filepath.Join(tmpDir, "Taskfile.yaml"), data, 0644) + testFile := filepath.Join(tmpDir, "fubar/foo.txt") + os.MkdirAll(filepath.Dir(testFile), 0755) + os.WriteFile(testFile, []byte("hello world"), 0644) + + // Correct test case paths. + tc.path = filepath.Join(tmpDir, tc.path) + + // Start the Task. + var buf bytes.Buffer + e := task.NewExecutor( + task.WithDir(tmpDir), + task.WithStdout(&buf), + task.WithStderr(&buf), + task.WithWatch(true), + task.WithVerbose(true), + ) + require.NoError(t, e.Setup()) + ctx, cancel := context.WithCancel(context.Background()) + go func() { + for { + select { + case <-ctx.Done(): + return + default: + err := e.Run(ctx, &task.Call{Task: "default"}) + if err != nil { + panic(err) + } + } + } + }() + + // Introduce the test condition. + time.Sleep(200 * time.Millisecond) + switch tc.action { + case "create": + f, _ := os.OpenFile(tc.path, os.O_CREATE|os.O_WRONLY, 0644) + defer f.Close() + f.WriteString("watch test") + checks = append(checks, `watch event: CREATE`) + + case "remove": + if !tc.expectRestart { + f, _ := os.OpenFile(tc.path, os.O_CREATE|os.O_WRONLY, 0644) + f.Close() + time.Sleep(100 * time.Millisecond) + checks = append(checks, `watch event: CREATE`) + } + os.Remove(tc.path) + checks = append(checks, `watch event: REMOVE`) + + case "rename": + if !tc.expectRestart { + f, _ := os.OpenFile(tc.path, os.O_CREATE|os.O_WRONLY, 0644) + f.Close() + time.Sleep(100 * time.Millisecond) + checks = append(checks, `watch event: CREATE`) + } + dir := filepath.Dir(tc.path) + base := filepath.Base(tc.path) + ext := filepath.Ext(base) + name := base[:len(base)-len(ext)] + _b := []byte(name) + slices.Reverse(_b) + name = string(_b) + os.Rename(tc.path, filepath.Join(dir, name+ext)) + checks = append(checks, `watch event: RENAME`) + + case "write": + f, _ := os.OpenFile(tc.path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) + defer f.Close() + f.WriteString("watch test") + checks = append(checks, `watch event: WRITE`) + } + + // Observe the expected conditions. + time.Sleep(200 * time.Millisecond) + cancel() + if tc.expectRestart { + checks = append(checks, `echo "Task running!"`) + } else { + checks = append(checks, `skipped for file not in sources:`) + } + + output := buf.String() + t.Log(output) + for _, check := range checks { + if idx := strings.Index(output, check); idx == -1 { + t.Log(output) + t.Log(checks) + t.Fatalf("Expected output not observed in sequence: %s", check) + } else { + output = output[idx+len(check):] + } + } + }) + } +} From cfeca066bea493d29105e20fe1c5072e370c7aa7 Mon Sep 17 00:00:00 2001 From: Timothy Rule <34501912+trulede@users.noreply.github.com> Date: Fri, 20 Mar 2026 22:01:19 +0100 Subject: [PATCH 2/2] Update watch_test.go --- watch_test.go | 4 ---- 1 file changed, 4 deletions(-) diff --git a/watch_test.go b/watch_test.go index ac7c8211c2..63759ac597 100644 --- a/watch_test.go +++ b/watch_test.go @@ -103,10 +103,6 @@ func TestShouldIgnore(t *testing.T) { } } -// Create, Remove, Rename, Write -// In sources, not in sources -// sources is a ./**/*.txt - func TestWatchSources(t *testing.T) { t.Parallel()