e4641798
feat(watcher): fsnotify with WRITE+CREATE+RENAME, debounce, ignore
a73x 2026-04-29 05:55
Commit message
go.mod
| Old | New | ||
|---|---|---|---|
| @@ -2,4 +2,8 @@ module github.com/xanderle/claudealong | |||
| 2 | 2 | ||
| 3 | go 1.26.2 | 3 | go 1.26.2 |
| 4 | 4 | ||
| 5 | require github.com/sabhiram/go-gitignore v0.0.0-20210923224102-525f6e181f06 // indirect | 5 | require ( |
| 6 | github.com/fsnotify/fsnotify v1.9.0 // indirect | ||
| 7 | github.com/sabhiram/go-gitignore v0.0.0-20210923224102-525f6e181f06 // indirect | ||
| 8 | golang.org/x/sys v0.13.0 // indirect | ||
| 9 | ) | ||
go.sum
| Old | New | ||
|---|---|---|---|
| @@ -1,8 +1,12 @@ | |||
| 1 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= | 1 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= |
| 2 | github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= | ||
| 3 | github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= | ||
| 2 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= | 4 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= |
| 3 | github.com/sabhiram/go-gitignore v0.0.0-20210923224102-525f6e181f06 h1:OkMGxebDjyw0ULyrTYWeN0UNCCkmCWfjPnIA2W6oviI= | 5 | github.com/sabhiram/go-gitignore v0.0.0-20210923224102-525f6e181f06 h1:OkMGxebDjyw0ULyrTYWeN0UNCCkmCWfjPnIA2W6oviI= |
| 4 | github.com/sabhiram/go-gitignore v0.0.0-20210923224102-525f6e181f06/go.mod h1:+ePHsJ1keEjQtpvf9HHw0f4ZeJ0TLRsxhunSI2hYJSs= | 6 | github.com/sabhiram/go-gitignore v0.0.0-20210923224102-525f6e181f06/go.mod h1:+ePHsJ1keEjQtpvf9HHw0f4ZeJ0TLRsxhunSI2hYJSs= |
| 5 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= | 7 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= |
| 6 | github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= | 8 | github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= |
| 9 | golang.org/x/sys v0.13.0 h1:Af8nKPmuFypiUBjVoU9V20FiaFXOcuZI21p0ycVYYGE= | ||
| 10 | golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= | ||
| 7 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= | 11 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= |
| 8 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= | 12 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= |
internal/watcher/watcher.go
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,153 @@ | |||
| 1 | package watcher | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "context" | ||
| 5 | "errors" | ||
| 6 | "os" | ||
| 7 | "path/filepath" | ||
| 8 | "sync" | ||
| 9 | "time" | ||
| 10 | |||
| 11 | "github.com/fsnotify/fsnotify" | ||
| 12 | "github.com/xanderle/claudealong/internal/ignore" | ||
| 13 | ) | ||
| 14 | |||
| 15 | type Event struct { | ||
| 16 | Path string | ||
| 17 | } | ||
| 18 | |||
| 19 | type Watcher struct { | ||
| 20 | root string | ||
| 21 | ig *ignore.Matcher | ||
| 22 | debounce time.Duration | ||
| 23 | |||
| 24 | fs *fsnotify.Watcher | ||
| 25 | events chan Event | ||
| 26 | |||
| 27 | mu sync.Mutex | ||
| 28 | pending map[string]*time.Timer | ||
| 29 | closed bool | ||
| 30 | } | ||
| 31 | |||
| 32 | func New(root string, ig *ignore.Matcher, debounce time.Duration) (*Watcher, error) { | ||
| 33 | fw, err := fsnotify.NewWatcher() | ||
| 34 | if err != nil { | ||
| 35 | return nil, err | ||
| 36 | } | ||
| 37 | return &Watcher{ | ||
| 38 | root: root, | ||
| 39 | ig: ig, | ||
| 40 | debounce: debounce, | ||
| 41 | fs: fw, | ||
| 42 | events: make(chan Event, 16), | ||
| 43 | pending: make(map[string]*time.Timer), | ||
| 44 | }, nil | ||
| 45 | } | ||
| 46 | |||
| 47 | func (w *Watcher) Events() <-chan Event { return w.events } | ||
| 48 | |||
| 49 | func (w *Watcher) Start(ctx context.Context) error { | ||
| 50 | if err := w.addRecursive(w.root); err != nil { | ||
| 51 | return err | ||
| 52 | } | ||
| 53 | go w.run(ctx) | ||
| 54 | return nil | ||
| 55 | } | ||
| 56 | |||
| 57 | func (w *Watcher) addRecursive(root string) error { | ||
| 58 | return filepath.Walk(root, func(p string, info os.FileInfo, err error) error { | ||
| 59 | if err != nil { | ||
| 60 | return nil // best-effort | ||
| 61 | } | ||
| 62 | if !info.IsDir() { | ||
| 63 | return nil | ||
| 64 | } | ||
| 65 | if w.ig.ShouldIgnore(p) { | ||
| 66 | return filepath.SkipDir | ||
| 67 | } | ||
| 68 | // Skip symlinked dirs | ||
| 69 | if info.Mode()&os.ModeSymlink != 0 { | ||
| 70 | return filepath.SkipDir | ||
| 71 | } | ||
| 72 | return w.fs.Add(p) | ||
| 73 | }) | ||
| 74 | } | ||
| 75 | |||
| 76 | func (w *Watcher) run(ctx context.Context) { | ||
| 77 | defer close(w.events) | ||
| 78 | for { | ||
| 79 | select { | ||
| 80 | case <-ctx.Done(): | ||
| 81 | return | ||
| 82 | case ev, ok := <-w.fs.Events: | ||
| 83 | if !ok { | ||
| 84 | return | ||
| 85 | } | ||
| 86 | w.handle(ev) | ||
| 87 | case _, ok := <-w.fs.Errors: | ||
| 88 | if !ok { | ||
| 89 | return | ||
| 90 | } | ||
| 91 | } | ||
| 92 | } | ||
| 93 | } | ||
| 94 | |||
| 95 | func (w *Watcher) handle(ev fsnotify.Event) { | ||
| 96 | if w.ig.ShouldIgnore(ev.Name) { | ||
| 97 | return | ||
| 98 | } | ||
| 99 | info, err := os.Lstat(ev.Name) | ||
| 100 | if err == nil && info.Mode()&os.ModeSymlink != 0 { | ||
| 101 | return // skip symlinks | ||
| 102 | } | ||
| 103 | // Re-watch on rename/create of a directory | ||
| 104 | if ev.Op&fsnotify.Create != 0 && err == nil && info.IsDir() { | ||
| 105 | _ = w.addRecursive(ev.Name) | ||
| 106 | return | ||
| 107 | } | ||
| 108 | if ev.Op&(fsnotify.Write|fsnotify.Create|fsnotify.Rename) == 0 { | ||
| 109 | return | ||
| 110 | } | ||
| 111 | if err == nil && info.IsDir() { | ||
| 112 | return | ||
| 113 | } | ||
| 114 | w.scheduleEvent(ev.Name) | ||
| 115 | } | ||
| 116 | |||
| 117 | func (w *Watcher) scheduleEvent(path string) { | ||
| 118 | w.mu.Lock() | ||
| 119 | defer w.mu.Unlock() | ||
| 120 | if w.closed { | ||
| 121 | return | ||
| 122 | } | ||
| 123 | if t, ok := w.pending[path]; ok { | ||
| 124 | t.Stop() | ||
| 125 | } | ||
| 126 | w.pending[path] = time.AfterFunc(w.debounce, func() { | ||
| 127 | w.mu.Lock() | ||
| 128 | delete(w.pending, path) | ||
| 129 | closed := w.closed | ||
| 130 | w.mu.Unlock() | ||
| 131 | if closed { | ||
| 132 | return | ||
| 133 | } | ||
| 134 | select { | ||
| 135 | case w.events <- Event{Path: path}: | ||
| 136 | default: | ||
| 137 | // drop if buffer full | ||
| 138 | } | ||
| 139 | }) | ||
| 140 | } | ||
| 141 | |||
| 142 | func (w *Watcher) Close() error { | ||
| 143 | w.mu.Lock() | ||
| 144 | w.closed = true | ||
| 145 | for _, t := range w.pending { | ||
| 146 | t.Stop() | ||
| 147 | } | ||
| 148 | w.mu.Unlock() | ||
| 149 | if err := w.fs.Close(); err != nil && !errors.Is(err, os.ErrClosed) { | ||
| 150 | return err | ||
| 151 | } | ||
| 152 | return nil | ||
| 153 | } | ||
internal/watcher/watcher_test.go
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,164 @@ | |||
| 1 | package watcher | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "context" | ||
| 5 | "os" | ||
| 6 | "path/filepath" | ||
| 7 | "testing" | ||
| 8 | "time" | ||
| 9 | |||
| 10 | "github.com/xanderle/claudealong/internal/ignore" | ||
| 11 | ) | ||
| 12 | |||
| 13 | func newTestMatcher(t *testing.T, dir string) *ignore.Matcher { | ||
| 14 | t.Helper() | ||
| 15 | m, err := ignore.New(dir) | ||
| 16 | if err != nil { | ||
| 17 | t.Fatal(err) | ||
| 18 | } | ||
| 19 | return m | ||
| 20 | } | ||
| 21 | |||
| 22 | func TestWatcherFiresOnInPlaceWrite(t *testing.T) { | ||
| 23 | dir := t.TempDir() | ||
| 24 | p := filepath.Join(dir, "a.go") | ||
| 25 | if err := os.WriteFile(p, []byte("x"), 0o644); err != nil { | ||
| 26 | t.Fatal(err) | ||
| 27 | } | ||
| 28 | |||
| 29 | w, err := New(dir, newTestMatcher(t, dir), 50*time.Millisecond) | ||
| 30 | if err != nil { | ||
| 31 | t.Fatal(err) | ||
| 32 | } | ||
| 33 | defer w.Close() | ||
| 34 | |||
| 35 | ctx, cancel := context.WithCancel(context.Background()) | ||
| 36 | defer cancel() | ||
| 37 | if err := w.Start(ctx); err != nil { | ||
| 38 | t.Fatal(err) | ||
| 39 | } | ||
| 40 | |||
| 41 | // Trigger WRITE | ||
| 42 | time.Sleep(20 * time.Millisecond) | ||
| 43 | if err := os.WriteFile(p, []byte("y"), 0o644); err != nil { | ||
| 44 | t.Fatal(err) | ||
| 45 | } | ||
| 46 | |||
| 47 | select { | ||
| 48 | case ev := <-w.Events(): | ||
| 49 | if ev.Path != p { | ||
| 50 | t.Errorf("got path %q, want %q", ev.Path, p) | ||
| 51 | } | ||
| 52 | case <-time.After(2 * time.Second): | ||
| 53 | t.Fatal("no event received") | ||
| 54 | } | ||
| 55 | } | ||
| 56 | |||
| 57 | func TestWatcherFiresOnAtomicSave(t *testing.T) { | ||
| 58 | dir := t.TempDir() | ||
| 59 | p := filepath.Join(dir, "a.go") | ||
| 60 | if err := os.WriteFile(p, []byte("x"), 0o644); err != nil { | ||
| 61 | t.Fatal(err) | ||
| 62 | } | ||
| 63 | |||
| 64 | w, err := New(dir, newTestMatcher(t, dir), 50*time.Millisecond) | ||
| 65 | if err != nil { | ||
| 66 | t.Fatal(err) | ||
| 67 | } | ||
| 68 | defer w.Close() | ||
| 69 | ctx, cancel := context.WithCancel(context.Background()) | ||
| 70 | defer cancel() | ||
| 71 | if err := w.Start(ctx); err != nil { | ||
| 72 | t.Fatal(err) | ||
| 73 | } | ||
| 74 | time.Sleep(20 * time.Millisecond) | ||
| 75 | |||
| 76 | // Atomic-save: write tmpfile + rename over original | ||
| 77 | tmp := filepath.Join(dir, ".a.go.tmp") | ||
| 78 | if err := os.WriteFile(tmp, []byte("z"), 0o644); err != nil { | ||
| 79 | t.Fatal(err) | ||
| 80 | } | ||
| 81 | if err := os.Rename(tmp, p); err != nil { | ||
| 82 | t.Fatal(err) | ||
| 83 | } | ||
| 84 | |||
| 85 | select { | ||
| 86 | case ev := <-w.Events(): | ||
| 87 | if ev.Path != p { | ||
| 88 | t.Errorf("got path %q, want %q", ev.Path, p) | ||
| 89 | } | ||
| 90 | case <-time.After(2 * time.Second): | ||
| 91 | t.Fatal("no event after atomic save (inode re-watch broken?)") | ||
| 92 | } | ||
| 93 | } | ||
| 94 | |||
| 95 | func TestWatcherDebouncesBurst(t *testing.T) { | ||
| 96 | dir := t.TempDir() | ||
| 97 | p := filepath.Join(dir, "a.go") | ||
| 98 | if err := os.WriteFile(p, []byte("x"), 0o644); err != nil { | ||
| 99 | t.Fatal(err) | ||
| 100 | } | ||
| 101 | w, err := New(dir, newTestMatcher(t, dir), 200*time.Millisecond) | ||
| 102 | if err != nil { | ||
| 103 | t.Fatal(err) | ||
| 104 | } | ||
| 105 | defer w.Close() | ||
| 106 | ctx, cancel := context.WithCancel(context.Background()) | ||
| 107 | defer cancel() | ||
| 108 | if err := w.Start(ctx); err != nil { | ||
| 109 | t.Fatal(err) | ||
| 110 | } | ||
| 111 | time.Sleep(50 * time.Millisecond) | ||
| 112 | |||
| 113 | // Burst of writes within debounce window | ||
| 114 | for i := 0; i < 5; i++ { | ||
| 115 | os.WriteFile(p, []byte{'a' + byte(i)}, 0o644) | ||
| 116 | time.Sleep(20 * time.Millisecond) | ||
| 117 | } | ||
| 118 | |||
| 119 | // Drain for 500ms; expect exactly 1 event | ||
| 120 | count := 0 | ||
| 121 | deadline := time.After(500 * time.Millisecond) | ||
| 122 | loop: | ||
| 123 | for { | ||
| 124 | select { | ||
| 125 | case <-w.Events(): | ||
| 126 | count++ | ||
| 127 | case <-deadline: | ||
| 128 | break loop | ||
| 129 | } | ||
| 130 | } | ||
| 131 | if count != 1 { | ||
| 132 | t.Errorf("got %d events, want 1 (debounced)", count) | ||
| 133 | } | ||
| 134 | } | ||
| 135 | |||
| 136 | func TestWatcherSkipsIgnoredPaths(t *testing.T) { | ||
| 137 | dir := t.TempDir() | ||
| 138 | if err := os.MkdirAll(filepath.Join(dir, "node_modules"), 0o755); err != nil { | ||
| 139 | t.Fatal(err) | ||
| 140 | } | ||
| 141 | p := filepath.Join(dir, "node_modules", "a.go") | ||
| 142 | if err := os.WriteFile(p, []byte("x"), 0o644); err != nil { | ||
| 143 | t.Fatal(err) | ||
| 144 | } | ||
| 145 | w, err := New(dir, newTestMatcher(t, dir), 50*time.Millisecond) | ||
| 146 | if err != nil { | ||
| 147 | t.Fatal(err) | ||
| 148 | } | ||
| 149 | defer w.Close() | ||
| 150 | ctx, cancel := context.WithCancel(context.Background()) | ||
| 151 | defer cancel() | ||
| 152 | if err := w.Start(ctx); err != nil { | ||
| 153 | t.Fatal(err) | ||
| 154 | } | ||
| 155 | time.Sleep(50 * time.Millisecond) | ||
| 156 | os.WriteFile(p, []byte("y"), 0o644) | ||
| 157 | |||
| 158 | select { | ||
| 159 | case ev := <-w.Events(): | ||
| 160 | t.Fatalf("unexpected event for ignored path: %q", ev.Path) | ||
| 161 | case <-time.After(300 * time.Millisecond): | ||
| 162 | // pass | ||
| 163 | } | ||
| 164 | } | ||