Repository: gotestyourself/gotestsum
Pull request #198 (2021-05-28 19:26:56)

watch: add action for reloading watched directory list
When files and directories are added, sometimes they won't be noticed because `gotestsum` only watches directories which already contained `.go` files. This new `l` action makes it possible to add these new directories without restarting `gotestsum`.


Patch:
diff --git a/README.md b/README.md
index cc52fe3..f7ffe4c 100644
--- a/README.md
+++ b/README.md
@@ -309,8 +309,8 @@ When the `--watch` flag is set, `gotestsum` will watch directories using
 [file system notifications](https://pkg.go.dev/github.com/fsnotify/fsnotify).
 When a Go file in one of those directories is modified, `gotestsum` will run the
 tests for the package which contains the changed file. By default all
-directories under the current directory will be watched. Use the `--packages` flag
-to specify a different list.
+directories with at least one file with a `.go` extension, under the current
+directory will be watched. Use the `--packages` flag to specify a different list.
 
 While in watch mode, pressing some keys will perform an action:
 
@@ -321,6 +321,9 @@ While in watch mode, pressing some keys will perform an action:
   breakpoints can be added with [`runtime.Breakpoint`](https://golang.org/pkg/runtime/#Breakpoint)
   or by using the delve command prompt.
 * `a` will run tests for all packages, by using `./...` as the package selector.
+* `l` will scan the directory list again, and if there are any new directories
+  which contain a file with a `.go` extension, they will be added to the watch
+  list.
 
 Note that [delve] must be installed in order to use debug (`d`).
 
diff --git a/cmd/watch.go b/cmd/watch.go
index 7973364..0864ec5 100644
--- a/cmd/watch.go
+++ b/cmd/watch.go
@@ -22,15 +22,15 @@ type watchRuns struct {
 	prevExec *testjson.Execution
 }
 
-func (w *watchRuns) run(runOpts filewatcher.RunOptions) error {
-	if runOpts.Debug {
+func (w *watchRuns) run(event filewatcher.Event) error {
+	if event.Debug {
 		path, cleanup, err := delveInitFile(w.prevExec)
 		if err != nil {
 			return fmt.Errorf("failed to write delve init file: %w", err)
 		}
 		defer cleanup()
 		o := delveOpts{
-			pkgPath:      runOpts.PkgPath,
+			pkgPath:      event.PkgPath,
 			args:         w.opts.args,
 			initFilePath: path,
 		}
@@ -41,7 +41,7 @@ func (w *watchRuns) run(runOpts filewatcher.RunOptions) error {
 	}
 
 	opts := w.opts
-	opts.packages = []string{runOpts.PkgPath}
+	opts.packages = []string{event.PkgPath}
 	var err error
 	if w.prevExec, err = runSingle(&opts); !isExitCoder(err) {
 		return err
diff --git a/internal/filewatcher/term_unix.go b/internal/filewatcher/term_unix.go
index 303d6bc..267b8c9 100644
--- a/internal/filewatcher/term_unix.go
+++ b/internal/filewatcher/term_unix.go
@@ -12,18 +12,20 @@ import (
 	"gotest.tools/gotestsum/log"
 )
 
-type redoHandler struct {
-	ch    chan RunOptions
+type terminal struct {
+	ch    chan Event
 	reset func()
 }
 
-func newRedoHandler() *redoHandler {
-	h := &redoHandler{ch: make(chan RunOptions)}
-	h.SetupTerm()
+func newTerminal() *terminal {
+	h := &terminal{ch: make(chan Event)}
+	h.Start()
 	return h
 }
 
-func (r *redoHandler) SetupTerm() {
+// Start the terminal is non-blocking read mode. The terminal can be reset to
+// normal mode by calling Reset.
+func (r *terminal) Start() {
 	if r == nil {
 		return
 	}
@@ -59,7 +61,9 @@ func enableNonBlockingRead(fd int) (func(), error) {
 	return reset, nil
 }
 
-func (r *redoHandler) Run(ctx context.Context) {
+// Monitor the terminal for key presses. If the key press is associated with an
+// action, an event will be sent to channel returned by Events.
+func (r *terminal) Monitor(ctx context.Context) {
 	if r == nil {
 		return
 	}
@@ -76,13 +80,16 @@ func (r *redoHandler) Run(ctx context.Context) {
 		switch char {
 		case 'r':
 			chResume = make(chan struct{})
-			r.ch <- RunOptions{resume: chResume}
+			r.ch <- Event{resume: chResume}
 		case 'd':
 			chResume = make(chan struct{})
-			r.ch <- RunOptions{Debug: true, resume: chResume}
+			r.ch <- Event{Debug: true, resume: chResume}
 		case 'a':
 			chResume = make(chan struct{})
-			r.ch <- RunOptions{resume: chResume, PkgPath: "./..."}
+			r.ch <- Event{resume: chResume, PkgPath: "./..."}
+		case 'l':
+			chResume = make(chan struct{})
+			r.ch <- Event{resume: chResume, reloadPaths: true}
 		case '\n':
 			fmt.Println()
 			continue
@@ -98,14 +105,17 @@ func (r *redoHandler) Run(ctx context.Context) {
 	}
 }
 
-func (r *redoHandler) Ch() <-chan RunOptions {
+// Events returns a channel which will receive events when keys are pressed.
+// When an event is received, the caller must close the resume channel to
+// resume monitoring for events.
+func (r *terminal) Events() <-chan Event {
 	if r == nil {
 		return nil
 	}
 	return r.ch
 }
 
-func (r *redoHandler) ResetTerm() {
+func (r *terminal) Reset() {
 	if r != nil && r.reset != nil {
 		r.reset()
 	}
diff --git a/internal/filewatcher/term_windows.go b/internal/filewatcher/term_windows.go
index 5929d1e..2c22c1a 100644
--- a/internal/filewatcher/term_windows.go
+++ b/internal/filewatcher/term_windows.go
@@ -2,18 +2,18 @@ package filewatcher
 
 import "context"
 
-type redoHandler struct{}
+type terminal struct{}
 
-func newRedoHandler() *redoHandler {
+func newTerminal() *terminal {
 	return nil
 }
 
-func (r *redoHandler) Run(_ context.Context) {}
+func (r *terminal) Monitor(context.Context) {}
 
-func (r *redoHandler) Ch() <-chan RunOptions {
+func (r *terminal) Events() <-chan Event {
 	return nil
 }
 
-func (r *redoHandler) SetupTerm() {}
+func (r *terminal) Start() {}
 
-func (r *redoHandler) ResetTerm() {}
+func (r *terminal) Reset() {}
diff --git a/internal/filewatcher/watch.go b/internal/filewatcher/watch.go
index e80c4e5..13a8e23 100644
--- a/internal/filewatcher/watch.go
+++ b/internal/filewatcher/watch.go
@@ -15,36 +15,35 @@ import (
 
 const maxDepth = 7
 
-type RunOptions struct {
-	PkgPath string
-	Debug   bool
-	resume  chan struct{}
+type Event struct {
+	PkgPath     string
+	Debug       bool
+	resume      chan struct{}
+	reloadPaths bool
 }
 
-func Watch(dirs []string, run func(opts RunOptions) error) error {
+// Watch dirs for filesystem events, and run tests when .go files are saved.
+// nolint: gocyclo
+func Watch(dirs []string, run func(Event) error) error {
 	ctx, cancel := context.WithCancel(context.Background())
 	defer cancel()
 
-	toWatch := findAllDirs(dirs, maxDepth)
 	watcher, err := fsnotify.NewWatcher()
 	if err != nil {
 		return fmt.Errorf("failed to create file watcher: %w", err)
 	}
 	defer watcher.Close() // nolint: errcheck // always returns nil error
 
-	fmt.Printf("Watching %v directories. Use Ctrl-c to to stop a run or exit.\n", len(toWatch))
-	for _, dir := range toWatch {
-		if err = watcher.Add(dir); err != nil {
-			return fmt.Errorf("failed to watch directory %v: %w", dir, err)
-		}
+	if err := loadPaths(watcher, dirs); err != nil {
+		return err
 	}
 
 	timer := time.NewTimer(maxIdleTime)
 	defer timer.Stop()
 
-	redo := newRedoHandler()
-	defer redo.ResetTerm()
-	go redo.Run(ctx)
+	term := newTerminal()
+	defer term.Reset()
+	go term.Monitor(ctx)
 
 	h := &handler{last: time.Now(), fn: run}
 	for {
@@ -52,15 +51,23 @@ func Watch(dirs []string, run func(opts RunOptions) error) error {
 		case <-timer.C:
 			return fmt.Errorf("exceeded idle timeout while watching files")
 
-		case opts := <-redo.Ch():
+		case event := <-term.Events():
 			resetTimer(timer)
 
-			redo.ResetTerm()
-			if err := h.runTests(opts); err != nil {
-				return fmt.Errorf("failed to rerun tests for %v: %v", opts.PkgPath, err)
+			if event.reloadPaths {
+				if err := loadPaths(watcher, dirs); err != nil {
+					return err
+				}
+				close(event.resume)
+				continue
+			}
+
+			term.Reset()
+			if err := h.runTests(event); err != nil {
+				return fmt.Errorf("failed to rerun tests for %v: %v", event.PkgPath, err)
 			}
-			redo.SetupTerm()
-			close(opts.resume)
+			term.Start()
+			close(event.resume)
 
 		case event := <-watcher.Events:
 			resetTimer(timer)
@@ -89,6 +96,17 @@ func resetTimer(timer *time.Timer) {
 	timer.Reset(maxIdleTime)
 }
 
+func loadPaths(watcher *fsnotify.Watcher, dirs []string) error {
+	toWatch := findAllDirs(dirs, maxDepth)
+	fmt.Printf("Watching %v directories. Use Ctrl-c to to stop a run or exit.\n", len(toWatch))
+	for _, dir := range toWatch {
+		if err := watcher.Add(dir); err != nil {
+			return fmt.Errorf("failed to watch directory %v: %w", dir, err)
+		}
+	}
+	return nil
+}
+
 func findAllDirs(dirs []string, maxDepth int) []string {
 	if len(dirs) == 0 {
 		dirs = []string{"./..."}
@@ -200,7 +218,7 @@ func handleDirCreated(watcher *fsnotify.Watcher, event fsnotify.Event) (handled
 type handler struct {
 	last     time.Time
 	lastPath string
-	fn       func(opts RunOptions) error
+	fn       func(opts Event) error
 }
 
 const floodThreshold = 250 * time.Millisecond
@@ -218,10 +236,10 @@ func (h *handler) handleEvent(event fsnotify.Event) error {
 		log.Debugf("skipping event received less than %v after the previous", floodThreshold)
 		return nil
 	}
-	return h.runTests(RunOptions{PkgPath: "./" + filepath.Dir(event.Name)})
+	return h.runTests(Event{PkgPath: "./" + filepath.Dir(event.Name)})
 }
 
-func (h *handler) runTests(opts RunOptions) error {
+func (h *handler) runTests(opts Event) error {
 	if opts.PkgPath == "" {
 		opts.PkgPath = h.lastPath
 	}


Test patch:
diff --git a/internal/filewatcher/watch_test.go b/internal/filewatcher/watch_test.go
index 15f350f..f02f8a3 100644
--- a/internal/filewatcher/watch_test.go
+++ b/internal/filewatcher/watch_test.go
@@ -22,7 +22,7 @@ func TestHandler_HandleEvent(t *testing.T) {
 
 	fn := func(t *testing.T, tc testCase) {
 		var ran bool
-		run := func(opts RunOptions) error {
+		run := func(opts Event) error {
 			ran = true
 			return nil
 		}