a73x

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
	}
}