Skip to content

Commit d65f329

Browse files
Matt Keelerbutuzovtrulede
committed
Watcher does not ignore created files that do not match sources.
Co-authored-by: Oleg Butuzov <butuzov@made.ua> Co-authored-by: Timothy Rule <34501912+trulede@users.noreply.github.com>
1 parent 54bdcba commit d65f329

3 files changed

Lines changed: 226 additions & 24 deletions

File tree

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
version: '3'
2+
3+
tasks:
4+
default:
5+
sources:
6+
- "./**/*.txt"
7+
cmds:
8+
- echo "Task running!"

watch.go

Lines changed: 83 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,6 @@ import (
1515
"github.com/puzpuzpuz/xsync/v4"
1616

1717
"github.com/go-task/task/v3/errors"
18-
"github.com/go-task/task/v3/internal/filepathext"
1918
"github.com/go-task/task/v3/internal/fingerprint"
2019
"github.com/go-task/task/v3/internal/fsnotifyext"
2120
"github.com/go-task/task/v3/internal/logger"
@@ -25,6 +24,8 @@ import (
2524

2625
const defaultWaitTime = 100 * time.Millisecond
2726

27+
var refreshChan = make(chan string)
28+
2829
// watchTasks start watching the given tasks
2930
func (e *Executor) watchTasks(calls ...*Call) error {
3031
tasks := make([]string, len(calls))
@@ -68,44 +69,82 @@ func (e *Executor) watchTasks(calls ...*Call) error {
6869

6970
closeOnInterrupt(w)
7071

72+
watchFiles, err := e.collectSources(calls)
73+
if err != nil {
74+
cancel()
75+
return err
76+
}
7177
go func() {
7278
for {
7379
select {
80+
case path := <-refreshChan:
81+
// If a path is added its necessary to refresh the sources, otherwise the
82+
// watcher may not pick up any changes in that new path.
83+
_ = path
84+
watchFiles, err = e.collectSources(calls)
85+
if err != nil {
86+
e.Logger.Errf(logger.Red, "%v\n", err)
87+
continue
88+
}
89+
7490
case event, ok := <-eventsChan:
7591
if !ok {
7692
cancel()
7793
return
7894
}
7995
e.Logger.VerboseErrf(logger.Magenta, "task: received watch event: %v\n", event)
8096

81-
cancel()
82-
ctx, cancel = context.WithCancel(context.Background())
83-
84-
e.Compiler.ResetCache()
85-
86-
for _, c := range calls {
87-
go func() {
88-
if ShouldIgnore(event.Name) {
89-
e.Logger.VerboseErrf(logger.Magenta, "task: event skipped for being an ignored dir: %s\n", event.Name)
90-
return
97+
// Check if this watch event should be ignored.
98+
if ShouldIgnore(event.Name) {
99+
e.Logger.VerboseErrf(logger.Magenta, "task: event skipped for being an ignored dir: %s\n", event.Name)
100+
continue
101+
}
102+
if event.Has(fsnotify.Remove) || event.Has(fsnotify.Rename) || event.Has(fsnotify.Write) {
103+
if !slices.Contains(watchFiles, event.Name) {
104+
relPath := event.Name
105+
if rel, err := filepath.Rel(e.Dir, event.Name); err == nil {
106+
relPath = rel
91107
}
92-
t, err := e.GetTask(c)
93-
if err != nil {
94-
e.Logger.Errf(logger.Red, "%v\n", err)
95-
return
108+
e.Logger.VerboseErrf(logger.Magenta, "task: skipped for file not in sources: %s\n", relPath)
109+
continue
110+
}
111+
}
112+
if event.Has(fsnotify.Create) {
113+
createDir := false
114+
if info, err := os.Stat(event.Name); err == nil {
115+
if info.IsDir() {
116+
createDir = true
96117
}
97-
baseDir := filepathext.SmartJoin(e.Dir, t.Dir)
98-
files, err := e.collectSources(calls)
99-
if err != nil {
118+
}
119+
watchFiles, err = e.collectSources(calls)
120+
if err != nil {
121+
e.Logger.Errf(logger.Red, "%v\n", err)
122+
continue
123+
}
124+
125+
if createDir {
126+
// If the CREATE relates to a folder, update the registered watch dirs (immediately).
127+
if err := e.registerWatchedDirs(w, calls...); err != nil {
100128
e.Logger.Errf(logger.Red, "%v\n", err)
101-
return
102129
}
103-
104-
if !event.Has(fsnotify.Remove) && !slices.Contains(files, event.Name) {
105-
relPath, _ := filepath.Rel(baseDir, event.Name)
130+
} else {
131+
if !slices.Contains(watchFiles, event.Name) {
132+
relPath := event.Name
133+
if rel, err := filepath.Rel(e.Dir, event.Name); err == nil {
134+
relPath = rel
135+
}
106136
e.Logger.VerboseErrf(logger.Magenta, "task: skipped for file not in sources: %s\n", relPath)
107-
return
137+
continue
108138
}
139+
}
140+
}
141+
142+
// The watch event is good, restart the task calls.
143+
cancel()
144+
ctx, cancel = context.WithCancel(context.Background())
145+
e.Compiler.ResetCache()
146+
for _, c := range calls {
147+
go func() {
109148
err = e.RunTask(ctx, c)
110149
if err == nil {
111150
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
167206
if err != nil {
168207
return err
169208
}
209+
dirs := []string{}
170210
for _, f := range files {
171-
d := filepath.Dir(f)
211+
dir := filepath.Dir(f)
212+
if !slices.Contains(dirs, dir) {
213+
dirs = append(dirs, dir)
214+
}
215+
}
216+
217+
// Remove dirs from the watch, otherwise the watched dir may become stale and
218+
// if the dir is recreated, it will not trigger any watch events.
219+
e.watchedDirs.Range(func(dir string, value bool) bool {
220+
if !slices.Contains(dirs, dir) {
221+
e.watchedDirs.Delete(dir)
222+
}
223+
return true
224+
})
225+
226+
// Add new dirs to the watch.
227+
for _, d := range dirs {
172228
if isSet, ok := e.watchedDirs.Load(d); ok && isSet {
173229
continue
174230
}
@@ -181,6 +237,9 @@ func (e *Executor) registerWatchedDirs(w *fsnotify.Watcher, calls ...*Call) erro
181237
e.watchedDirs.Store(d, true)
182238
relPath, _ := filepath.Rel(e.Dir, d)
183239
e.Logger.VerboseOutf(logger.Green, "task: watching new dir: %v\n", relPath)
240+
241+
// Signal that the watcher should refresh its watch file list.
242+
refreshChan <- d
184243
}
185244
return nil
186245
}

watch_test.go

Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ import (
88
"context"
99
"fmt"
1010
"os"
11+
"path/filepath"
12+
"slices"
1113
"strings"
1214
"testing"
1315
"time"
@@ -100,3 +102,136 @@ func TestShouldIgnore(t *testing.T) {
100102
})
101103
}
102104
}
105+
106+
// Create, Remove, Rename, Write
107+
// In sources, not in sources
108+
// sources is a ./**/*.txt
109+
110+
func TestWatchSources(t *testing.T) {
111+
t.Parallel()
112+
113+
tests := []struct {
114+
action string
115+
path string
116+
expectRestart bool
117+
}{
118+
// Entry condition: file fubar/foo.txt exists.
119+
{"create", "fubar/bar.txt", true},
120+
{"remove", "fubar/foo.txt", true},
121+
{"rename", "fubar/foo.txt", true},
122+
{"write", "fubar/foo.txt", true},
123+
{"create", "fubar/bar.text", false},
124+
{"remove", "fubar/foo.text", false},
125+
{"rename", "fubar/foo.text", false},
126+
{"write", "fubar/foo.text", false},
127+
}
128+
129+
for _, tc := range tests {
130+
tc := tc
131+
t.Run(fmt.Sprintf("%s-%s", tc.action, tc.path), func(t *testing.T) {
132+
t.Parallel()
133+
134+
checks := []string{`Started watching for tasks: default`, `echo "Task running!"`}
135+
136+
// Setup the watch dir.
137+
tmpDir := t.TempDir()
138+
data, _ := os.ReadFile("testdata/watch/sources/Taskfile.yaml")
139+
os.WriteFile(filepath.Join(tmpDir, "Taskfile.yaml"), data, 0644)
140+
testFile := filepath.Join(tmpDir, "fubar/foo.txt")
141+
os.MkdirAll(filepath.Dir(testFile), 0755)
142+
os.WriteFile(testFile, []byte("hello world"), 0644)
143+
144+
// Correct test case paths.
145+
tc.path = filepath.Join(tmpDir, tc.path)
146+
147+
// Start the Task.
148+
var buf bytes.Buffer
149+
e := task.NewExecutor(
150+
task.WithDir(tmpDir),
151+
task.WithStdout(&buf),
152+
task.WithStderr(&buf),
153+
task.WithWatch(true),
154+
task.WithVerbose(true),
155+
)
156+
require.NoError(t, e.Setup())
157+
ctx, cancel := context.WithCancel(context.Background())
158+
go func() {
159+
for {
160+
select {
161+
case <-ctx.Done():
162+
return
163+
default:
164+
err := e.Run(ctx, &task.Call{Task: "default"})
165+
if err != nil {
166+
panic(err)
167+
}
168+
}
169+
}
170+
}()
171+
172+
// Introduce the test condition.
173+
time.Sleep(200 * time.Millisecond)
174+
switch tc.action {
175+
case "create":
176+
f, _ := os.OpenFile(tc.path, os.O_CREATE|os.O_WRONLY, 0644)
177+
defer f.Close()
178+
f.WriteString("watch test")
179+
checks = append(checks, `watch event: CREATE`)
180+
181+
case "remove":
182+
if !tc.expectRestart {
183+
f, _ := os.OpenFile(tc.path, os.O_CREATE|os.O_WRONLY, 0644)
184+
f.Close()
185+
time.Sleep(100 * time.Millisecond)
186+
checks = append(checks, `watch event: CREATE`)
187+
}
188+
os.Remove(tc.path)
189+
checks = append(checks, `watch event: REMOVE`)
190+
191+
case "rename":
192+
if !tc.expectRestart {
193+
f, _ := os.OpenFile(tc.path, os.O_CREATE|os.O_WRONLY, 0644)
194+
f.Close()
195+
time.Sleep(100 * time.Millisecond)
196+
checks = append(checks, `watch event: CREATE`)
197+
}
198+
dir := filepath.Dir(tc.path)
199+
base := filepath.Base(tc.path)
200+
ext := filepath.Ext(base)
201+
name := base[:len(base)-len(ext)]
202+
_b := []byte(name)
203+
slices.Reverse(_b)
204+
name = string(_b)
205+
os.Rename(tc.path, filepath.Join(dir, name+ext))
206+
checks = append(checks, `watch event: RENAME`)
207+
208+
case "write":
209+
f, _ := os.OpenFile(tc.path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
210+
defer f.Close()
211+
f.WriteString("watch test")
212+
checks = append(checks, `watch event: WRITE`)
213+
}
214+
215+
// Observe the expected conditions.
216+
time.Sleep(200 * time.Millisecond)
217+
cancel()
218+
if tc.expectRestart {
219+
checks = append(checks, `echo "Task running!"`)
220+
} else {
221+
checks = append(checks, `skipped for file not in sources:`)
222+
}
223+
224+
output := buf.String()
225+
t.Log(output)
226+
for _, check := range checks {
227+
if idx := strings.Index(output, check); idx == -1 {
228+
t.Log(output)
229+
t.Log(checks)
230+
t.Fatalf("Expected output not observed in sequence: %s", check)
231+
} else {
232+
output = output[idx+len(check):]
233+
}
234+
}
235+
})
236+
}
237+
}

0 commit comments

Comments
 (0)