gotest.tools/gotestsum@v1.11.0/internal/filewatcher/watch.go (about) 1 //go:build !aix 2 // +build !aix 3 4 package filewatcher 5 6 import ( 7 "context" 8 "fmt" 9 "io" 10 "os" 11 "path/filepath" 12 "strings" 13 "time" 14 15 "github.com/fsnotify/fsnotify" 16 "gotest.tools/gotestsum/internal/log" 17 ) 18 19 const maxDepth = 7 20 21 type Event struct { 22 // PkgPath of the package that triggered the event. 23 PkgPath string 24 // Args will be appended to the command line args for 'go test'. 25 Args []string 26 // Debug runs the tests with delve. 27 Debug bool 28 // resume the Watch goroutine when this channel is closed. Used to block 29 // the Watch goroutine while tests are running. 30 resume chan struct{} 31 // reloadPaths will cause the watched path list to be reloaded, to watch 32 // new directories. 33 reloadPaths bool 34 // useLastPath when true will use the PkgPath from the previous run. 35 useLastPath bool 36 } 37 38 // Watch dirs for filesystem events, and run tests when .go files are saved. 39 // nolint: gocyclo 40 func Watch(ctx context.Context, dirs []string, run func(Event) error) error { 41 watcher, err := fsnotify.NewWatcher() 42 if err != nil { 43 return fmt.Errorf("failed to create file watcher: %w", err) 44 } 45 defer watcher.Close() // nolint: errcheck // always returns nil error 46 47 if err := loadPaths(watcher, dirs); err != nil { 48 return err 49 } 50 51 timer := time.NewTimer(maxIdleTime) 52 defer timer.Stop() 53 54 term := newTerminal() 55 defer term.Reset() 56 go term.Monitor(ctx) 57 58 h := &fsEventHandler{last: time.Now(), fn: run} 59 for { 60 select { 61 case <-ctx.Done(): 62 return nil 63 case <-timer.C: 64 return fmt.Errorf("exceeded idle timeout while watching files") 65 66 case event := <-term.Events(): 67 resetTimer(timer) 68 69 if event.reloadPaths { 70 if err := loadPaths(watcher, dirs); err != nil { 71 return err 72 } 73 close(event.resume) 74 continue 75 } 76 77 term.Reset() 78 if err := h.runTests(event); err != nil { 79 return fmt.Errorf("failed to rerun tests for %v: %v", event.PkgPath, err) 80 } 81 term.Start() 82 close(event.resume) 83 84 case event := <-watcher.Events: 85 resetTimer(timer) 86 log.Debugf("handling event %v", event) 87 88 if handleDirCreated(watcher, event) { 89 continue 90 } 91 92 if err := h.handleEvent(event); err != nil { 93 return fmt.Errorf("failed to run tests for %v: %v", event.Name, err) 94 } 95 96 case err := <-watcher.Errors: 97 return fmt.Errorf("failed while watching files: %v", err) 98 } 99 } 100 } 101 102 const maxIdleTime = time.Hour 103 104 func resetTimer(timer *time.Timer) { 105 if !timer.Stop() { 106 <-timer.C 107 } 108 timer.Reset(maxIdleTime) 109 } 110 111 func loadPaths(watcher *fsnotify.Watcher, dirs []string) error { 112 toWatch := findAllDirs(dirs, maxDepth) 113 fmt.Printf("Watching %v directories. Use Ctrl-c to to stop a run or exit.\n", len(toWatch)) 114 for _, dir := range toWatch { 115 if err := watcher.Add(dir); err != nil { 116 return fmt.Errorf("failed to watch directory %v: %w", dir, err) 117 } 118 } 119 return nil 120 } 121 122 func findAllDirs(dirs []string, maxDepth int) []string { 123 if len(dirs) == 0 { 124 dirs = []string{"./..."} 125 } 126 127 var output []string // nolint: prealloc 128 for _, dir := range dirs { 129 const recur = "/..." 130 if strings.HasSuffix(dir, recur) { 131 dir = strings.TrimSuffix(dir, recur) 132 output = append(output, findSubDirs(dir, maxDepth)...) 133 continue 134 } 135 output = append(output, dir) 136 } 137 return output 138 } 139 140 func findSubDirs(rootDir string, maxDepth int) []string { 141 var output []string 142 // add root dir depth so that maxDepth is relative to the root dir 143 maxDepth += pathDepth(rootDir) 144 walker := func(path string, info os.FileInfo, err error) error { 145 if err != nil { 146 log.Warnf("failed to watch %v: %v", path, err) 147 return nil 148 } 149 if !info.IsDir() { 150 return nil 151 } 152 if pathDepth(path) > maxDepth || exclude(path) { 153 log.Debugf("Ignoring %v because of max depth or exclude list", path) 154 return filepath.SkipDir 155 } 156 if !hasGoFiles(path) { 157 log.Debugf("Ignoring %v because it has no .go files", path) 158 return nil 159 } 160 output = append(output, path) 161 return nil 162 } 163 // nolint: errcheck // error is handled by walker func 164 filepath.Walk(rootDir, walker) 165 return output 166 } 167 168 func pathDepth(path string) int { 169 return strings.Count(filepath.Clean(path), string(filepath.Separator)) 170 } 171 172 // return true if path is vendor, testdata, or starts with a dot 173 func exclude(path string) bool { 174 base := filepath.Base(path) 175 switch { 176 case strings.HasPrefix(base, ".") && len(base) > 1: 177 return true 178 case base == "vendor" || base == "testdata": 179 return true 180 } 181 return false 182 } 183 184 func hasGoFiles(path string) bool { 185 fh, err := os.Open(path) 186 if err != nil { 187 return false 188 } 189 defer fh.Close() // nolint: errcheck // fh is opened read-only 190 191 for { 192 names, err := fh.Readdirnames(20) 193 switch { 194 case err == io.EOF: 195 return false 196 case err != nil: 197 log.Warnf("failed to read directory %v: %v", path, err) 198 return false 199 } 200 201 for _, name := range names { 202 if strings.HasSuffix(name, ".go") { 203 return true 204 } 205 } 206 } 207 } 208 209 func handleDirCreated(watcher *fsnotify.Watcher, event fsnotify.Event) (handled bool) { 210 if event.Op&fsnotify.Create != fsnotify.Create { 211 return false 212 } 213 214 fileInfo, err := os.Stat(event.Name) 215 if err != nil { 216 log.Warnf("failed to stat %s: %s", event.Name, err) 217 return false 218 } 219 220 if !fileInfo.IsDir() { 221 return false 222 } 223 224 if err := watcher.Add(event.Name); err != nil { 225 log.Warnf("failed to watch new directory %v: %v", event.Name, err) 226 } 227 return true 228 } 229 230 type fsEventHandler struct { 231 last time.Time 232 lastPath string 233 fn func(opts Event) error 234 } 235 236 var floodThreshold = 250 * time.Millisecond 237 238 func (h *fsEventHandler) handleEvent(event fsnotify.Event) error { 239 if event.Op&(fsnotify.Write|fsnotify.Create|fsnotify.Rename) == 0 { 240 return nil 241 } 242 243 if !strings.HasSuffix(event.Name, ".go") { 244 return nil 245 } 246 247 if time.Since(h.last) < floodThreshold { 248 log.Debugf("skipping event received less than %v after the previous", floodThreshold) 249 return nil 250 } 251 return h.runTests(Event{PkgPath: "./" + filepath.Dir(event.Name)}) 252 } 253 254 func (h *fsEventHandler) runTests(opts Event) error { 255 if opts.useLastPath { 256 opts.PkgPath = h.lastPath 257 } 258 fmt.Printf("\nRunning tests in %v\n", opts.PkgPath) 259 260 if err := h.fn(opts); err != nil { 261 return err 262 } 263 h.last = time.Now() 264 h.lastPath = opts.PkgPath 265 return nil 266 }