diff --git a/README.md b/README.md index cc52fe3c..f7ffe4c4 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 79733646..0864ec5e 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 303d6bcc..267b8c93 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 5929d1e8..2c22c1ac 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 e80c4e5d..13a8e23a 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 } diff --git a/internal/filewatcher/watch_test.go b/internal/filewatcher/watch_test.go index 15f350f6..f02f8a3e 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 }