a73x

e4641798

feat(watcher): fsnotify with WRITE+CREATE+RENAME, debounce, ignore

a73x   2026-04-29 05:55

Commit message
feat(watcher): fsnotify with WRITE+CREATE+RENAME, debounce, ignore

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

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 }