e4641798
feat(watcher): fsnotify with WRITE+CREATE+RENAME, debounce, ignore
a73x 2026-04-29 05:55
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
diff --git a/go.mod b/go.mod index 385a388..47a7d14 100644 --- a/go.mod +++ b/go.mod @@ -2,4 +2,8 @@ module github.com/xanderle/claudealong go 1.26.2 require github.com/sabhiram/go-gitignore v0.0.0-20210923224102-525f6e181f06 // indirect require ( github.com/fsnotify/fsnotify v1.9.0 // indirect github.com/sabhiram/go-gitignore v0.0.0-20210923224102-525f6e181f06 // indirect golang.org/x/sys v0.13.0 // indirect ) diff --git a/go.sum b/go.sum index 1a4b4fc..ced19ef 100644 --- a/go.sum +++ b/go.sum @@ -1,8 +1,12 @@ github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/sabhiram/go-gitignore v0.0.0-20210923224102-525f6e181f06 h1:OkMGxebDjyw0ULyrTYWeN0UNCCkmCWfjPnIA2W6oviI= github.com/sabhiram/go-gitignore v0.0.0-20210923224102-525f6e181f06/go.mod h1:+ePHsJ1keEjQtpvf9HHw0f4ZeJ0TLRsxhunSI2hYJSs= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= golang.org/x/sys v0.13.0 h1:Af8nKPmuFypiUBjVoU9V20FiaFXOcuZI21p0ycVYYGE= golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/watcher/watcher.go b/internal/watcher/watcher.go new file mode 100644 index 0000000..e3d96b0 --- /dev/null +++ b/internal/watcher/watcher.go @@ -0,0 +1,153 @@ package watcher import ( "context" "errors" "os" "path/filepath" "sync" "time" "github.com/fsnotify/fsnotify" "github.com/xanderle/claudealong/internal/ignore" ) type Event struct { Path string } type Watcher struct { root string ig *ignore.Matcher debounce time.Duration fs *fsnotify.Watcher events chan Event mu sync.Mutex pending map[string]*time.Timer closed bool } func New(root string, ig *ignore.Matcher, debounce time.Duration) (*Watcher, error) { fw, err := fsnotify.NewWatcher() if err != nil { return nil, err } return &Watcher{ root: root, ig: ig, debounce: debounce, fs: fw, events: make(chan Event, 16), pending: make(map[string]*time.Timer), }, nil } func (w *Watcher) Events() <-chan Event { return w.events } func (w *Watcher) Start(ctx context.Context) error { if err := w.addRecursive(w.root); err != nil { return err } go w.run(ctx) return nil } func (w *Watcher) addRecursive(root string) error { return filepath.Walk(root, func(p string, info os.FileInfo, err error) error { if err != nil { return nil // best-effort } if !info.IsDir() { return nil } if w.ig.ShouldIgnore(p) { return filepath.SkipDir } // Skip symlinked dirs if info.Mode()&os.ModeSymlink != 0 { return filepath.SkipDir } return w.fs.Add(p) }) } func (w *Watcher) run(ctx context.Context) { defer close(w.events) for { select { case <-ctx.Done(): return case ev, ok := <-w.fs.Events: if !ok { return } w.handle(ev) case _, ok := <-w.fs.Errors: if !ok { return } } } } func (w *Watcher) handle(ev fsnotify.Event) { if w.ig.ShouldIgnore(ev.Name) { return } info, err := os.Lstat(ev.Name) if err == nil && info.Mode()&os.ModeSymlink != 0 { return // skip symlinks } // Re-watch on rename/create of a directory if ev.Op&fsnotify.Create != 0 && err == nil && info.IsDir() { _ = w.addRecursive(ev.Name) return } if ev.Op&(fsnotify.Write|fsnotify.Create|fsnotify.Rename) == 0 { return } if err == nil && info.IsDir() { return } w.scheduleEvent(ev.Name) } func (w *Watcher) scheduleEvent(path string) { w.mu.Lock() defer w.mu.Unlock() if w.closed { return } if t, ok := w.pending[path]; ok { t.Stop() } w.pending[path] = time.AfterFunc(w.debounce, func() { w.mu.Lock() delete(w.pending, path) closed := w.closed w.mu.Unlock() if closed { return } select { case w.events <- Event{Path: path}: default: // drop if buffer full } }) } func (w *Watcher) Close() error { w.mu.Lock() w.closed = true for _, t := range w.pending { t.Stop() } w.mu.Unlock() if err := w.fs.Close(); err != nil && !errors.Is(err, os.ErrClosed) { return err } return nil } diff --git a/internal/watcher/watcher_test.go b/internal/watcher/watcher_test.go new file mode 100644 index 0000000..f944057 --- /dev/null +++ b/internal/watcher/watcher_test.go @@ -0,0 +1,164 @@ package watcher import ( "context" "os" "path/filepath" "testing" "time" "github.com/xanderle/claudealong/internal/ignore" ) func newTestMatcher(t *testing.T, dir string) *ignore.Matcher { t.Helper() m, err := ignore.New(dir) if err != nil { t.Fatal(err) } return m } func TestWatcherFiresOnInPlaceWrite(t *testing.T) { dir := t.TempDir() p := filepath.Join(dir, "a.go") if err := os.WriteFile(p, []byte("x"), 0o644); err != nil { t.Fatal(err) } w, err := New(dir, newTestMatcher(t, dir), 50*time.Millisecond) if err != nil { t.Fatal(err) } defer w.Close() ctx, cancel := context.WithCancel(context.Background()) defer cancel() if err := w.Start(ctx); err != nil { t.Fatal(err) } // Trigger WRITE time.Sleep(20 * time.Millisecond) if err := os.WriteFile(p, []byte("y"), 0o644); err != nil { t.Fatal(err) } select { case ev := <-w.Events(): if ev.Path != p { t.Errorf("got path %q, want %q", ev.Path, p) } case <-time.After(2 * time.Second): t.Fatal("no event received") } } func TestWatcherFiresOnAtomicSave(t *testing.T) { dir := t.TempDir() p := filepath.Join(dir, "a.go") if err := os.WriteFile(p, []byte("x"), 0o644); err != nil { t.Fatal(err) } w, err := New(dir, newTestMatcher(t, dir), 50*time.Millisecond) if err != nil { t.Fatal(err) } defer w.Close() ctx, cancel := context.WithCancel(context.Background()) defer cancel() if err := w.Start(ctx); err != nil { t.Fatal(err) } time.Sleep(20 * time.Millisecond) // Atomic-save: write tmpfile + rename over original tmp := filepath.Join(dir, ".a.go.tmp") if err := os.WriteFile(tmp, []byte("z"), 0o644); err != nil { t.Fatal(err) } if err := os.Rename(tmp, p); err != nil { t.Fatal(err) } select { case ev := <-w.Events(): if ev.Path != p { t.Errorf("got path %q, want %q", ev.Path, p) } case <-time.After(2 * time.Second): t.Fatal("no event after atomic save (inode re-watch broken?)") } } func TestWatcherDebouncesBurst(t *testing.T) { dir := t.TempDir() p := filepath.Join(dir, "a.go") if err := os.WriteFile(p, []byte("x"), 0o644); err != nil { t.Fatal(err) } w, err := New(dir, newTestMatcher(t, dir), 200*time.Millisecond) if err != nil { t.Fatal(err) } defer w.Close() ctx, cancel := context.WithCancel(context.Background()) defer cancel() if err := w.Start(ctx); err != nil { t.Fatal(err) } time.Sleep(50 * time.Millisecond) // Burst of writes within debounce window for i := 0; i < 5; i++ { os.WriteFile(p, []byte{'a' + byte(i)}, 0o644) time.Sleep(20 * time.Millisecond) } // Drain for 500ms; expect exactly 1 event count := 0 deadline := time.After(500 * time.Millisecond) loop: for { select { case <-w.Events(): count++ case <-deadline: break loop } } if count != 1 { t.Errorf("got %d events, want 1 (debounced)", count) } } func TestWatcherSkipsIgnoredPaths(t *testing.T) { dir := t.TempDir() if err := os.MkdirAll(filepath.Join(dir, "node_modules"), 0o755); err != nil { t.Fatal(err) } p := filepath.Join(dir, "node_modules", "a.go") if err := os.WriteFile(p, []byte("x"), 0o644); err != nil { t.Fatal(err) } w, err := New(dir, newTestMatcher(t, dir), 50*time.Millisecond) if err != nil { t.Fatal(err) } defer w.Close() ctx, cancel := context.WithCancel(context.Background()) defer cancel() if err := w.Start(ctx); err != nil { t.Fatal(err) } time.Sleep(50 * time.Millisecond) os.WriteFile(p, []byte("y"), 0o644) select { case ev := <-w.Events(): t.Fatalf("unexpected event for ignored path: %q", ev.Path) case <-time.After(300 * time.Millisecond): // pass } }