Repository: Shopify/themekit (go)

Issue:
Theme Watch triggering for all image files in /assets
### What happened
When I do `theme watch` lately, it will usually run as expected for 10-30 minutes, until eventually it perceives all the images files in /assets as having been updated, and starts trying to upload them all.

### What I expected to happen
Only upload files when they're actually changed.

### Steps to reproduce

1. Theme watch
2. Work on theme for about half an hour as normal
3. Observe in the command line, "Received Update event on assets/img.png" for every image in the assets folder

### My Environment

OS (including version): Windows 10 v 1803
Themekit version: 0.7.5
Ignore Files(if any):


Pull request description:
Changing the file watcher backend
fixes #519 

fsnotify is beginning to collect a lot of tech debt and it has been unreliable to be consistent across operating systems.

It currently is broken on windows 10 because windows sends events even when a file is viewed. The new backend that I have added uses pollling across all systems, and while not as ideal, it uses checksums and timestamps to check changes and smooth out events. 

Also I have not seen any cpu usage spike after switching. It still hovers around 0.4%

### Warn Checklist
- [x] This changes the interface and requires a Major/Minor version change.
- [x] I have :tophat:'d these changes by using the commands I changed by hand.
- [x] I have added a dependancy to the project.


Patch:
diff --git a/changelog.txt b/changelog.txt
index 2346b94..6f089c3 100644
--- a/changelog.txt
+++ b/changelog.txt
@@ -7,6 +7,10 @@ v1.0.0
 - Added environment variable interpolation into config files.
 - Added a global environment variable file so that variables can be easily
 managed
+- Changed the file watcher backend to use a polling strategy. This will work
+more consistently across all platforms without worries of changes in what
+events each operating systems send.
+- Fixes multiple unrequired file events
 
 v0.8.1 (Sept 18, 2018)
 ======================
diff --git a/cmd/watch.go b/cmd/watch.go
index 3e22f9d..6246fa8 100644
--- a/cmd/watch.go
+++ b/cmd/watch.go
@@ -38,15 +38,11 @@ var watchCmd = &cobra.Command{
 			if err != nil {
 				return err
 			}
-			watchEvents, err := watcher.Watch()
-			if err != nil {
-				return err
-			}
+			watcher.Watch()
 			defer watcher.Stop()
-
 			signalChan := make(chan os.Signal)
 			signal.Notify(signalChan, os.Interrupt)
-			return watch(ctx, watchEvents, signalChan)
+			return watch(ctx, watcher.Events, signalChan)
 		})
 	},
 }
diff --git a/go.mod b/go.mod
index 7a21ca7..8770d6c 100644
--- a/go.mod
+++ b/go.mod
@@ -6,7 +6,7 @@ require (
 	github.com/caarlos0/env v0.0.0-20161013201842-d0de832ed2fb
 	github.com/davecgh/go-spew v1.1.1 // indirect
 	github.com/fatih/color v1.7.0
-	github.com/fsnotify/fsnotify v1.4.7
+	github.com/fsnotify/fsnotify v1.4.7 // indirect
 	github.com/hashicorp/go-version v0.0.0-20180716215031-270f2f71b1ee
 	github.com/imdario/mergo v0.3.6
 	github.com/inconshreveable/go-update v0.0.0-20160112193335-8152e7eb6ccf
@@ -15,6 +15,7 @@ require (
 	github.com/mattn/go-colorable v0.0.0-20180310133214-efa589957cd0
 	github.com/mattn/go-isatty v0.0.4 // indirect
 	github.com/pmezard/go-difflib v1.0.0 // indirect
+	github.com/radovskyb/watcher v1.0.2
 	github.com/ryanuber/go-glob v0.0.0-20160226084822-572520ed46db
 	github.com/shibukawa/configdir v0.0.0-20170330084843-e180dbdc8da0
 	github.com/skratchdot/open-golang v0.0.0-20160302144031-75fb7ed4208c
diff --git a/go.sum b/go.sum
index 2c4576e..922bdfa 100644
--- a/go.sum
+++ b/go.sum
@@ -30,6 +30,8 @@ github.com/mattn/go-isatty v0.0.4 h1:bnP0vzxcAdeI1zdubAl5PjU6zsERjGZb7raWodagDYs
 github.com/mattn/go-isatty v0.0.4/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4=
 github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
 github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
+github.com/radovskyb/watcher v1.0.2 h1:9L5TsZUbo1nKhQEQPtICVc+x9UZQ6VPdBepLHyGw/bQ=
+github.com/radovskyb/watcher v1.0.2/go.mod h1:78okwvY5wPdzcb1UYnip1pvrZNIVEIh/Cm+ZuvsUYIg=
 github.com/ryanuber/go-glob v0.0.0-20160226084822-572520ed46db h1:ge9atzKq16843f793fDVxKUhmTb4H5muzjJQ6PgsnHg=
 github.com/ryanuber/go-glob v0.0.0-20160226084822-572520ed46db/go.mod h1:807d1WSdnB0XRJzKNil9Om6lcp/3a0v4qIHxIXzX/Yc=
 github.com/shibukawa/configdir v0.0.0-20170330084843-e180dbdc8da0 h1:Xuk8ma/ibJ1fOy4Ee11vHhUFHQNpHhrBneOCNHVXS5w=
diff --git a/src/file/_testdata/project/assets/application.js b/src/file/_testdata/project/assets/application.js
new file mode 100644
index 0000000..e69de29
diff --git a/src/file/_testdata/project/config.yml b/src/file/_testdata/project/config.yml
new file mode 100644
index 0000000..e69de29
diff --git a/src/file/_testdata/project/config/settings.json b/src/file/_testdata/project/config/settings.json
new file mode 100644
index 0000000..e69de29
diff --git a/src/file/_testdata/project/templates/customers/test.liquid b/src/file/_testdata/project/templates/customers/test.liquid
new file mode 100644
index 0000000..e69de29
diff --git a/src/file/_testdata/project/templates/foo.liquid b/src/file/_testdata/project/templates/foo.liquid
new file mode 100644
index 0000000..e69de29
diff --git a/src/file/_testdata/project/templates/template.liquid b/src/file/_testdata/project/templates/template.liquid
new file mode 100644
index 0000000..e69de29
diff --git a/src/file/watcher.go b/src/file/watcher.go
index 9d41b05..e0bc925 100644
--- a/src/file/watcher.go
+++ b/src/file/watcher.go
@@ -4,10 +4,11 @@ import (
 	"fmt"
 	"os"
 	"path/filepath"
+	"strings"
 	"time"
 
 	"github.com/Shopify/themekit/src/env"
-	"github.com/fsnotify/fsnotify"
+	"github.com/radovskyb/watcher"
 )
 
 // Op describes the different types of file operations
@@ -18,6 +19,7 @@ const (
 	Update Op = iota
 	// Remove is a file op where the file is removed
 	Remove
+	filepathSplit = " -> "
 )
 
 // Event decsribes a file change event
@@ -26,17 +28,16 @@ type Event struct {
 	Path string
 }
 
-type debouncer func(timeout time.Duration, events, complete chan fsnotify.Event)
-
 // Watcher is the object used to watch files for change and notify on any events,
 // these events can then be passed along to kit to be sent to shopify.
 type Watcher struct {
-	fsWatcher       *fsnotify.Watcher
+	Events chan Event
+
+	fsWatcher       *watcher.Watcher
 	filter          Filter
 	notify          string
 	directory       string
 	configPath      string
-	events          chan Event
 	debounceTimeout time.Duration
 	idleTimeout     time.Duration
 }
@@ -49,10 +50,35 @@ func NewWatcher(e *env.Env, configPath string) (*Watcher, error) {
 		return nil, err
 	}
 
+	fsWatcher := watcher.New()
+	fsWatcher.IgnoreHiddenFiles(true)
+	fsWatcher.FilterOps(
+		watcher.Create,
+		watcher.Write,
+		watcher.Remove,
+		watcher.Rename,
+		watcher.Move,
+	)
+
+	if configPath != "" {
+		if err := fsWatcher.Add(configPath); err != nil {
+			return nil, fmt.Errorf("Could not watch config path %s: %s", configPath, err)
+		}
+	}
+
+	for _, folder := range assetLocations {
+		path := filepath.Join(e.Directory, folder)
+		if err := fsWatcher.Add(path); err != nil && !os.IsNotExist(err) {
+			return nil, fmt.Errorf("Could not watch directory %s: %s", path, err)
+		}
+	}
+
 	return &Watcher{
+		Events:          make(chan Event),
 		configPath:      configPath,
 		directory:       e.Directory,
 		filter:          filter,
+		fsWatcher:       fsWatcher,
 		notify:          e.Notify,
 		debounceTimeout: 1100 * time.Millisecond,
 		idleTimeout:     time.Second,
@@ -61,86 +87,86 @@ func NewWatcher(e *env.Env, configPath string) (*Watcher, error) {
 
 // Watch will start the watcher actually receiving file change events and sending
 // events to the Events channel
-func (w *Watcher) Watch() (chan Event, error) {
-	fsWatcher, err := fsnotify.NewWatcher()
-	if err != nil {
-		return nil, err
-	}
-
-	w.fsWatcher = fsWatcher
-	w.events = make(chan Event)
-
-	go w.watchFsEvents(fsWatcher.Events, debounce)
-
-	if err := fsWatcher.Add(w.configPath); err != nil {
-		return nil, fmt.Errorf("Could not config path: %s", err)
-	}
-
-	return w.events, filepath.Walk(w.directory, func(path string, info os.FileInfo, err error) error {
-		if err != nil {
-			return err
-		}
-
-		if info.IsDir() && !w.filter.Match(path) && path != w.directory {
-			if err := fsWatcher.Add(path); err != nil {
-				return fmt.Errorf("Could not watch directory %s: %s", path, err)
-			}
-		}
-		return nil
-	})
+func (w *Watcher) Watch() {
+	go w.watchFsEvents()
+	go w.fsWatcher.Start(w.debounceTimeout)
 }
 
-func (w *Watcher) watchFsEvents(events chan fsnotify.Event, debounce debouncer) {
-	fileEvents := make(map[string]chan fsnotify.Event)
-	complete := make(chan fsnotify.Event)
+func (w *Watcher) watchFsEvents() {
 	idleTimer := time.NewTimer(w.idleTimeout)
 	defer idleTimer.Stop()
-
 	for {
 		select {
-		case event := <-complete:
-			projectPath := pathToProject(w.directory, event.Name)
-			if projectPath == "" {
-				projectPath = event.Name
-			}
-			e := Event{Op: Update, Path: projectPath}
-			if event.Op&fsnotify.Remove == fsnotify.Remove || event.Op&fsnotify.Rename == fsnotify.Rename {
-				e.Op = Remove
-			}
-			w.events <- e
-			delete(fileEvents, event.Name)
-			if len(fileEvents) == 0 {
+		case event, ok := <-w.fsWatcher.Event:
+			if ok && w.onEvent(event) {
 				idleTimer.Reset(w.idleTimeout)
 			}
-		case event, more := <-events:
-			if !more {
-				close(w.events)
-				return
-			}
-
-			if event.Op == fsnotify.Chmod || (w.configPath != event.Name && w.filter.Match(event.Name)) {
-				continue
-			}
-
-			idleTimer.Stop()
-			if _, ok := fileEvents[event.Name]; !ok {
-				fileEvents[event.Name] = make(chan fsnotify.Event)
-				go debounce(w.debounceTimeout, fileEvents[event.Name], complete)
-			}
-			fileEvents[event.Name] <- event
 		case <-idleTimer.C:
 			w.onIdle()
+		case <-w.fsWatcher.Closed:
+			w.Stop()
+			return
 		}
 	}
 }
 
+func (w *Watcher) onEvent(event watcher.Event) bool {
+	if event.IsDir() {
+		return false
+	}
+
+	oldPath, currentPath := w.parsePath(event.Path)
+	if w.configPath != event.Path && w.filter.Match(currentPath) {
+		return false
+	}
+
+	var events []Event
+	if isEventType(event.Op, watcher.Rename, watcher.Move) {
+		events = append(events, Event{Op: Remove, Path: oldPath}, Event{Op: Update, Path: currentPath})
+	} else if isEventType(event.Op, watcher.Remove) {
+		events = append(events, Event{Op: Remove, Path: currentPath})
+	} else if isEventType(event.Op, watcher.Create, watcher.Write) {
+		events = append(events, Event{Op: Update, Path: currentPath})
+	}
+
+	for _, e := range events {
+		w.Events <- e
+	}
+
+	return len(events) > 0
+}
+
+func (w *Watcher) parsePath(path string) (old, current string) {
+	parts := strings.Split(path, filepathSplit)
+	for i, path := range parts {
+		projectPath := pathToProject(w.directory, path)
+		if projectPath == "" {
+			projectPath = path
+		}
+		parts[i] = projectPath
+	}
+	if len(parts) > 1 {
+		return parts[0], parts[1]
+	}
+	return "", parts[0]
+}
+
+func isEventType(currentOp watcher.Op, allowedOps ...watcher.Op) bool {
+	for _, op := range allowedOps {
+		if currentOp == op {
+			return true
+		}
+	}
+	return false
+}
+
 // Stop will stop the Watcher from watching it's directories and clean
 // up any go routines doing work.
 func (w *Watcher) Stop() {
-	if w.fsWatcher == nil {
-		return
-	}
 	w.fsWatcher.Close()
+	for len(w.Events) > 0 { // drain events
+		<-w.Events
+	}
 }
 
 func (w *Watcher) onIdle() {
@@ -150,15 +176,3 @@ func (w *Watcher) onIdle() {
 	os.Create(w.notify)
 	os.Chtimes(w.notify, time.Now(), time.Now())
 }
-
-func debounce(timeout time.Duration, incoming, complete chan fsnotify.Event) {
-	var event fsnotify.Event
-	for {
-		select {
-		case event = <-incoming:
-		case <-time.After(timeout):
-			complete <- event
-			return
-		}
-	}
-}


Test patch:
diff --git a/src/file/watcher_test.go b/src/file/watcher_test.go
index 34a74be..73faf78 100644
--- a/src/file/watcher_test.go
+++ b/src/file/watcher_test.go
@@ -1,131 +1,179 @@
 package file
 
 import (
+	"fmt"
 	"os"
 	"path/filepath"
 	"testing"
 	"time"
 
-	"github.com/fsnotify/fsnotify"
+	"github.com/radovskyb/watcher"
 	"github.com/stretchr/testify/assert"
 
 	"github.com/Shopify/themekit/src/env"
 )
 
 func TestNewFileWatcher(t *testing.T) {
-	e := env.Env{
-		Directory:    "/tmp",
-		IgnoredFiles: []string{"*.png"},
-		Notify:       "/tmp/notify",
-	}
-	filter, _ := NewFilter(e.Directory, e.IgnoredFiles, []string{})
-	watcher, err := NewWatcher(&e, "")
-	assert.Nil(t, err)
-	assert.Equal(t, watcher.filter, filter)
-	assert.Equal(t, watcher.notify, e.Notify)
+	w := createTestWatcher(t)
+	filter, _ := NewFilter(filepath.Join("_testdata", "project"), []string{"config/"}, []string{})
+	assert.Equal(t, w.filter, filter)
+	assert.Equal(t, w.notify, "/tmp/notifytest")
+	assert.NotNil(t, w.fsWatcher)
+	assert.NotNil(t, w.Events)
+	watchedFiles := w.fsWatcher.WatchedFiles()
+	assert.Equal(t, 14, len(watchedFiles))
 }
 
-func TestFileWatcher_WatchDirectory(t *testing.T) {
-	e := env.Env{
+func TestFileWatcher_Watch(t *testing.T) {
+	e := &env.Env{
 		Directory:    filepath.Join("_testdata", "project"),
 		IgnoredFiles: []string{"config"},
 	}
+	w, _ := NewWatcher(e, "")
+	w.Watch()
+
+	path := filepath.Join("_testdata", "project", "assets", "application.js")
+	info, _ := os.Stat(path)
+	w.fsWatcher.Wait()
+	w.fsWatcher.Event <- watcher.Event{Op: watcher.Create, Path: path, FileInfo: info}
+
+	select {
+	case <-w.Events:
+	case <-time.After(time.Second):
+		t.Error("Didnt process an event so must not be watching")
+	}
 
-	watcher, _ := NewWatcher(&e, "")
-	watcher.Watch()
-	assert.NotNil(t, watcher.fsWatcher)
-	assert.NotNil(t, watcher.events)
-	assert.NotNil(t, watcher.fsWatcher.Remove(filepath.Join("_testdata", "project", "config")))
-	assert.Nil(t, watcher.fsWatcher.Remove(filepath.Join("_testdata", "project", "assets")))
-	watcher.Stop()
+	w.Stop()
 }
 
 func TestFileWatcher_WatchFsEvents(t *testing.T) {
-	watcher, _ := NewWatcher(&env.Env{Directory: "_testdata/project", IgnoredFiles: []string{"config/settings.json"}}, "")
-	watcher.events = make(chan Event)
-	events := make(chan fsnotify.Event)
-	go watcher.watchFsEvents(events, func(t time.Duration, events, complete chan fsnotify.Event) { complete <- (<-events) })
-
 	testcases := []struct {
-		event         fsnotify.Event
+		filename      string
+		op            watcher.Op
 		shouldReceive bool
 		expectedOp    Op
 	}{
-		{shouldReceive: false, event: fsnotify.Event{Name: "_testdata/project/config/settings.json", Op: fsnotify.Write}},
-		{shouldReceive: false, event: fsnotify.Event{Name: "_testdata/project/templates/foo.liquid", Op: fsnotify.Chmod}},
-		{shouldReceive: true, expectedOp: Update, event: fsnotify.Event{Name: "_testdata/project/templates/template.liquid", Op: fsnotify.Write}},
-		{shouldReceive: true, expectedOp: Update, event: fsnotify.Event{Name: "_testdata/project/templates/customers/test.liquid", Op: fsnotify.Write}},
-		{shouldReceive: true, expectedOp: Remove, event: fsnotify.Event{Name: "_testdata/project/templates/customers/test.liquid", Op: fsnotify.Remove}},
-		{shouldReceive: true, expectedOp: Remove, event: fsnotify.Event{Name: "_testdata/project/templates/customers/test.liquid", Op: fsnotify.Rename}},
+		{shouldReceive: false, filename: "_testdata/project/config/settings.json", op: watcher.Write},
+		{shouldReceive: true, expectedOp: Update, filename: "_testdata/project/templates/template.liquid", op: watcher.Write},
+		{shouldReceive: true, expectedOp: Update, filename: "_testdata/project/templates/customers/test.liquid", op: watcher.Write},
+		{shouldReceive: true, expectedOp: Remove, filename: "_testdata/project/templates/customers/test.liquid", op: watcher.Remove},
+		{shouldReceive: true, expectedOp: Remove, filename: "_testdata/project/templates/customers/test.liquid", op: watcher.Rename},
 	}
 
+	w := createTestWatcher(t)
+	w.Watch()
+	w.Events = make(chan Event, len(testcases))
+	defer w.Stop()
 	for _, testcase := range testcases {
-		events <- testcase.event
+		info, _ := os.Stat(testcase.filename)
+		w.fsWatcher.Event <- watcher.Event{Op: testcase.op, Path: testcase.filename, FileInfo: info}
 		if testcase.shouldReceive {
-			e := <-watcher.events
-			assert.Contains(t, testcase.event.Name, e.Path)
+			e := <-w.Events
+			assert.Contains(t, testcase.filename, e.Path)
 			assert.Equal(t, testcase.expectedOp, e.Op)
 		} else {
-			assert.False(t, len(watcher.events) > 0, testcase.event.Name)
+			assert.False(t, len(w.Events) > 0, testcase.filename)
 		}
 	}
+}
+
+func TestFileWatcher_OnEvent(t *testing.T) {
+	testcases := []struct {
+		filename   string
+		op         watcher.Op
+		expectedOp []Op
+	}{
+		{expectedOp: []Op{}, filename: "_testdata/project/templates/customers", op: watcher.Write},
+		{expectedOp: []Op{}, filename: "_testdata/project/config/settings.json", op: watcher.Write},
+		{expectedOp: []Op{Update}, filename: "_testdata/project/templates/template.liquid", op: watcher.Write},
+		{expectedOp: []Op{Update}, filename: "_testdata/project/templates/customers/test.liquid", op: watcher.Create},
+		{expectedOp: []Op{Remove}, filename: "_testdata/project/templates/customers/test.liquid", op: watcher.Remove},
+		{expectedOp: []Op{Remove, Update}, filename: "_testdata/project/assets/application.js.liquid -> _testdata/project/assets/application.js", op: watcher.Rename},
+		{expectedOp: []Op{Remove, Update}, filename: "_testdata/project/assets/application.js.liquid -> _testdata/project/assets/application.js", op: watcher.Move},
+	}
 
-	// Shutdown sequence works
-	close(events)
-	_, ok := <-watcher.events
-	assert.False(t, ok)
+	w := createTestWatcher(t)
+	w.Events = make(chan Event, len(testcases))
+	defer w.Stop()
+	for i, testcase := range testcases {
+		_, currentPath := w.parsePath(testcase.filename)
+		info, _ := os.Stat(filepath.Join("_testdata", "project", currentPath))
+		w.onEvent(watcher.Event{Op: testcase.op, Path: testcase.filename, FileInfo: info})
+		assert.Equal(t, len(testcase.expectedOp), len(w.Events), fmt.Sprintf("testcase: %v", i))
+		for i := 0; i < len(testcase.expectedOp); i++ {
+			e := <-w.Events
+			assert.Contains(t, testcase.filename, e.Path)
+			assert.Equal(t, testcase.expectedOp[i], e.Op)
+		}
+	}
+}
+
+func TestFileWatcher_ParsePath(t *testing.T) {
+	testcases := []struct {
+		input, oldpath, currentpath string
+	}{
+		{"_testdata/project/assets/app.js", "", "assets/app.js"},
+		{"_testdata/project/assets/app.js -> _testdata/project/assets/app.js.liquid", "assets/app.js", "assets/app.js.liquid"},
+		{"not/another/path/assets/app.js", "", "not/another/path/assets/app.js"},
+	}
+	w := createTestWatcher(t)
+	for _, testcase := range testcases {
+		o, c := w.parsePath(testcase.input)
+		assert.Equal(t, o, testcase.oldpath)
+		assert.Equal(t, c, testcase.currentpath)
+	}
+}
+
+func TestIsEventType(t *testing.T) {
+	expectedOps := []watcher.Op{watcher.Write, watcher.Remove, watcher.Rename}
+	refutedOps := []watcher.Op{watcher.Chmod, watcher.Create, watcher.Move}
+
+	for _, op := range expectedOps {
+		assert.True(t, isEventType(op, expectedOps...), fmt.Sprintf("%v", op))
+	}
+
+	for _, op := range refutedOps {
+		assert.False(t, isEventType(op, expectedOps...), fmt.Sprintf("%v", op))
+	}
 }
 
 func TestFileWatcher_StopWatching(t *testing.T) {
-	watcher, err := NewWatcher(&env.Env{Directory: "_testdata/project"}, "")
-	assert.Nil(t, err)
-	watcher.Stop()
-	watcher.Watch()
-	watcher.Stop()
-	_, ok := <-watcher.events
-	assert.False(t, ok)
+	w := createTestWatcher(t)
+	w.Stop()
+	w.Watch()
+	w.Stop()
 }
 
 func TestFileWatcher_TouchNotifyFile(t *testing.T) {
 	notifyPath := filepath.Join("_testdata", "notify_file")
-	watcher, _ := NewWatcher(&env.Env{Notify: notifyPath}, "")
+	w, _ := NewWatcher(&env.Env{Notify: notifyPath}, "")
 
 	os.Remove(notifyPath)
 	_, err := os.Stat(notifyPath)
 	assert.True(t, os.IsNotExist(err))
 
-	watcher.onIdle()
+	w.onIdle()
 	_, err = os.Stat(notifyPath)
 	assert.Nil(t, err)
 	// need to make the time different larger than milliseconds because windows
 	// trucates the time and it will fail
-	os.Chtimes(watcher.notify, time.Now().AddDate(0, 0, -1), time.Now().AddDate(0, 0, -1))
+	os.Chtimes(w.notify, time.Now().AddDate(0, 0, -1), time.Now().AddDate(0, 0, -1))
 	info1, err := os.Stat(notifyPath)
 	assert.Nil(t, err)
 
-	watcher.onIdle()
+	w.onIdle()
 	info2, err := os.Stat(notifyPath)
 	assert.Nil(t, err)
 	assert.NotEqual(t, info1.ModTime(), info2.ModTime())
 }
 
-func TestDebounce(t *testing.T) {
-	writes := []fsnotify.Event{
-		{Name: "_testdata/project/assets/application.js", Op: fsnotify.Create},
-		{Name: "_testdata/project/assets/application.js", Op: fsnotify.Write},
-		{Name: "_testdata/project/assets/application.js", Op: fsnotify.Remove},
-		{Name: "_testdata/project/assets/application.js", Op: fsnotify.Rename},
-	}
-
-	events := make(chan fsnotify.Event)
-	complete := make(chan fsnotify.Event)
-	go debounce(time.Millisecond, events, complete)
-
-	for _, write := range writes {
-		events <- write
+func createTestWatcher(t *testing.T) *Watcher {
+	e := &env.Env{
+		Directory:    filepath.Join("_testdata", "project"),
+		IgnoredFiles: []string{"config/"},
+		Notify:       "/tmp/notifytest",
 	}
-
-	e := <-complete
-	assert.Equal(t, e.Name, "_testdata/project/assets/application.js")
-	assert.Equal(t, e.Op, fsnotify.Rename)
+	w, err := NewWatcher(e, filepath.Join("_testdata", "project", "config.yml"))
+	assert.Nil(t, err)
+	return w
 }