@@ -2,7 +2,13 @@ package main
22
33import (
44 "context"
5+ "fmt"
6+ "os"
7+ "os/exec"
8+ "path/filepath"
9+ "strings"
510 "sync"
11+ "time"
612)
713
814// ---------------------------------------------------------------------------
@@ -13,6 +19,19 @@ import (
1319var watchCancel context.CancelFunc
1420var watchMu sync.Mutex
1521
22+ // watchConfig holds debounce and filter settings for the watcher.
23+ var watchConfig = struct {
24+ debounce time.Duration
25+ // include is a list of glob suffixes to watch (e.g. ".go", ".ts").
26+ // If empty, all files are watched.
27+ include []string
28+ // exclude is a list of path substrings to ignore.
29+ exclude []string
30+ }{
31+ debounce : 300 * time .Millisecond ,
32+ exclude : []string {".git" , "node_modules" , ".iterate" },
33+ }
34+
1635func stopWatch () {
1736 watchMu .Lock ()
1837 defer watchMu .Unlock ()
@@ -21,3 +40,130 @@ func stopWatch() {
2140 watchCancel = nil
2241 }
2342}
43+
44+ // startWatch starts a polling-based file watcher that runs `go test ./...`
45+ // (or a custom command) whenever a watched file changes.
46+ // It debounces rapid changes and respects include/exclude filter patterns.
47+ func startWatch (repoPath string ) {
48+ watchMu .Lock ()
49+ if watchCancel != nil {
50+ watchCancel () // stop any previous watcher
51+ }
52+ ctx , cancel := context .WithCancel (context .Background ())
53+ watchCancel = cancel
54+ watchMu .Unlock ()
55+
56+ go runWatcher (ctx , repoPath )
57+ }
58+
59+ // runWatcher polls for file modifications using mtimes (no fsnotify dependency).
60+ func runWatcher (ctx context.Context , repoPath string ) {
61+ snapshots := snapshotMTimes (repoPath )
62+
63+ var debounceTimer * time.Timer
64+ var debounceMu sync.Mutex
65+
66+ ticker := time .NewTicker (500 * time .Millisecond )
67+ defer ticker .Stop ()
68+
69+ for {
70+ select {
71+ case <- ctx .Done ():
72+ return
73+ case <- ticker .C :
74+ current := snapshotMTimes (repoPath )
75+ changed := diffSnapshots (snapshots , current )
76+ snapshots = current
77+
78+ if len (changed ) == 0 {
79+ continue
80+ }
81+
82+ // Debounce: reset timer on each burst of changes.
83+ debounceMu .Lock ()
84+ if debounceTimer != nil {
85+ debounceTimer .Stop ()
86+ }
87+ changedCopy := changed
88+ debounceTimer = time .AfterFunc (watchConfig .debounce , func () {
89+ fmt .Printf ("\n %s[watch] %d file(s) changed — running tests…%s\n " ,
90+ colorYellow , len (changedCopy ), colorReset )
91+ for _ , p := range changedCopy {
92+ rel , _ := filepath .Rel (repoPath , p )
93+ fmt .Printf (" %s%s%s\n " , colorDim , rel , colorReset )
94+ }
95+ runWatchTests (repoPath )
96+ })
97+ debounceMu .Unlock ()
98+ }
99+ }
100+ }
101+
102+ // snapshotMTimes returns a map of path → mtime for all watched files.
103+ func snapshotMTimes (repoPath string ) map [string ]time.Time {
104+ result := make (map [string ]time.Time )
105+ _ = filepath .Walk (repoPath , func (path string , info os.FileInfo , err error ) error {
106+ if err != nil || info == nil {
107+ return nil
108+ }
109+ if info .IsDir () {
110+ // Skip excluded directories.
111+ for _ , ex := range watchConfig .exclude {
112+ if strings .Contains (path , ex ) {
113+ return filepath .SkipDir
114+ }
115+ }
116+ return nil
117+ }
118+ if ! shouldWatch (path ) {
119+ return nil
120+ }
121+ result [path ] = info .ModTime ()
122+ return nil
123+ })
124+ return result
125+ }
126+
127+ // shouldWatch returns true if path passes include/exclude filters.
128+ func shouldWatch (path string ) bool {
129+ for _ , ex := range watchConfig .exclude {
130+ if strings .Contains (path , ex ) {
131+ return false
132+ }
133+ }
134+ if len (watchConfig .include ) == 0 {
135+ return true
136+ }
137+ for _ , inc := range watchConfig .include {
138+ if strings .HasSuffix (path , inc ) {
139+ return true
140+ }
141+ }
142+ return false
143+ }
144+
145+ // diffSnapshots returns paths that are new or have a newer mtime.
146+ func diffSnapshots (old , current map [string ]time.Time ) []string {
147+ var changed []string
148+ for path , newTime := range current {
149+ if oldTime , ok := old [path ]; ! ok || newTime .After (oldTime ) {
150+ changed = append (changed , path )
151+ }
152+ }
153+ return changed
154+ }
155+
156+ // runWatchTests runs the test command and prints output.
157+ func runWatchTests (repoPath string ) {
158+ cmd := exec .Command ("go" , "test" , "./..." )
159+ cmd .Dir = repoPath
160+ out , err := cmd .CombinedOutput ()
161+ if err != nil {
162+ fmt .Printf ("%s[watch] tests FAILED%s\n %s\n " , colorRed , colorReset , string (out ))
163+ } else {
164+ fmt .Printf ("%s[watch] ✓ tests passed%s\n " , colorLime , colorReset )
165+ if len (out ) > 0 {
166+ fmt .Printf ("%s%s%s\n " , colorDim , strings .TrimSpace (string (out )), colorReset )
167+ }
168+ }
169+ }
0 commit comments