aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorBernhard Guillon <Bernhard.Guillon@begu.org>2026-07-06 21:26:29 +0200
committerBernhard Guillon <Bernhard.Guillon@begu.org>2026-07-06 21:26:29 +0200
commit8b22a36d1c068f9baee68799b0c1f51ecb999e9d (patch)
treef460fd80e8c1578db2bd365b52caf1734d473b73
parent7512c95fa3f4b4e4ccdf452024f314a9a3c3b60f (diff)
downloadsourcewatch-8b22a36d1c068f9baee68799b0c1f51ecb999e9d.tar.gz
sourcewatch-8b22a36d1c068f9baee68799b0c1f51ecb999e9d.zip
Add watcher tests, refactor watch() to accept context
- Added watcher_test.go: 10 tests covering create, write, remove, rename, ignored files, debouncing, directory creation/removal, and manifest updates - Refactored watch() to accept context.Context for clean cancellation - Added signal handling (SIGINT/SIGTERM) for graceful shutdown in main.go - Fixed test expectations (version two = 11 bytes, not 10)
-rw-r--r--main.go9
-rw-r--r--watcher.go6
-rw-r--r--watcher_test.go325
3 files changed, 338 insertions, 2 deletions
diff --git a/main.go b/main.go
index 345436f..436c7b7 100644
--- a/main.go
+++ b/main.go
@@ -1,8 +1,12 @@
package main
import (
+ "context"
"flag"
"log"
+ "os"
+ "os/signal"
+ "syscall"
)
var (
@@ -31,7 +35,10 @@ func main() {
log.Fatalf("initial sync failed: %v", err)
}
- if err := watch(*hostDir, *dataDir, manifest, rules, *dryRun, *verbose); err != nil {
+ ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
+ defer cancel()
+
+ if err := watch(ctx, *hostDir, *dataDir, manifest, rules, *dryRun, *verbose); err != nil {
log.Fatalf("watcher failed: %v", err)
}
}
diff --git a/watcher.go b/watcher.go
index 7b823da..2b56bb4 100644
--- a/watcher.go
+++ b/watcher.go
@@ -1,6 +1,7 @@
package main
import (
+ "context"
"log"
"os"
"path/filepath"
@@ -12,7 +13,7 @@ import (
ignore "github.com/sabhiram/go-gitignore"
)
-func watch(hostDir, dataDir string, manifest *Manifest, rules *ignore.GitIgnore, dryRun, verbose bool) error {
+func watch(ctx context.Context, hostDir, dataDir string, manifest *Manifest, rules *ignore.GitIgnore, dryRun, verbose bool) error {
watcher, err := fsnotify.NewWatcher()
if err != nil {
return err
@@ -28,6 +29,9 @@ func watch(hostDir, dataDir string, manifest *Manifest, rules *ignore.GitIgnore,
for {
select {
+ case <-ctx.Done():
+ return nil
+
case event, ok := <-watcher.Events:
if !ok {
return nil
diff --git a/watcher_test.go b/watcher_test.go
new file mode 100644
index 0000000..0ea6ca2
--- /dev/null
+++ b/watcher_test.go
@@ -0,0 +1,325 @@
+package main
+
+import (
+ "context"
+ "os"
+ "path/filepath"
+ "testing"
+ "time"
+
+ ignore "github.com/sabhiram/go-gitignore"
+)
+
+func TestWatcher_Create(t *testing.T) {
+ host := t.TempDir()
+ data := t.TempDir()
+
+ rules := ignore.CompileIgnoreLines()
+ manifest, err := initialSync(host, data, rules, false)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ ctx, cancel := context.WithCancel(context.Background())
+ defer cancel()
+ go watch(ctx, host, data, manifest, rules, false, false)
+
+ time.Sleep(100 * time.Millisecond)
+
+ writeFile(t, host, "new.txt", "hello")
+ assertFileEventually(t, data, "new.txt", "hello", 2*time.Second)
+}
+
+func TestWatcher_CreateInSubdir(t *testing.T) {
+ host := t.TempDir()
+ data := t.TempDir()
+
+ writeFile(t, host, "src/existing.go", "old")
+
+ rules := ignore.CompileIgnoreLines()
+ manifest, err := initialSync(host, data, rules, false)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ ctx, cancel := context.WithCancel(context.Background())
+ defer cancel()
+ go watch(ctx, host, data, manifest, rules, false, false)
+
+ time.Sleep(100 * time.Millisecond)
+
+ writeFile(t, host, "src/new.go", "package main")
+ assertFileEventually(t, data, "src/new.go", "package main", 2*time.Second)
+}
+
+func TestWatcher_Write(t *testing.T) {
+ host := t.TempDir()
+ data := t.TempDir()
+
+ writeFile(t, host, "file.txt", "original")
+
+ rules := ignore.CompileIgnoreLines()
+ manifest, err := initialSync(host, data, rules, false)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ assertFileExists(t, data, "file.txt", "original")
+
+ ctx, cancel := context.WithCancel(context.Background())
+ defer cancel()
+ go watch(ctx, host, data, manifest, rules, false, false)
+
+ time.Sleep(100 * time.Millisecond)
+
+ writeFile(t, host, "file.txt", "updated")
+ assertFileEventually(t, data, "file.txt", "updated", 2*time.Second)
+}
+
+func TestWatcher_Remove(t *testing.T) {
+ host := t.TempDir()
+ data := t.TempDir()
+
+ writeFile(t, host, "file.txt", "content")
+
+ rules := ignore.CompileIgnoreLines()
+ manifest, err := initialSync(host, data, rules, false)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ assertFileExists(t, data, "file.txt", "content")
+
+ ctx, cancel := context.WithCancel(context.Background())
+ defer cancel()
+ go watch(ctx, host, data, manifest, rules, false, false)
+
+ time.Sleep(100 * time.Millisecond)
+
+ os.Remove(filepath.Join(host, "file.txt"))
+ assertFileRemovedEventually(t, data, "file.txt", 2*time.Second)
+}
+
+func TestWatcher_Rename(t *testing.T) {
+ host := t.TempDir()
+ data := t.TempDir()
+
+ writeFile(t, host, "Foo.txt", "content")
+
+ rules := ignore.CompileIgnoreLines()
+ manifest, err := initialSync(host, data, rules, false)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ assertFileExists(t, data, "Foo.txt", "content")
+
+ ctx, cancel := context.WithCancel(context.Background())
+ defer cancel()
+ go watch(ctx, host, data, manifest, rules, false, false)
+
+ time.Sleep(100 * time.Millisecond)
+
+ // Rename: delete old, create new (Linux is case-sensitive)
+ os.Rename(filepath.Join(host, "Foo.txt"), filepath.Join(host, "foo.txt"))
+ writeFile(t, host, "foo.txt", "content")
+
+ assertFileRemovedEventually(t, data, "Foo.txt", 2*time.Second)
+ assertFileEventually(t, data, "foo.txt", "content", 2*time.Second)
+}
+
+func TestWatcher_IgnoredFileEvents(t *testing.T) {
+ host := t.TempDir()
+ data := t.TempDir()
+
+ writeFile(t, host, ".gitignore", "*.log\nbuild/\n")
+
+ rules, err := loadGitignore(host)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ manifest, err := initialSync(host, data, rules, false)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ ctx, cancel := context.WithCancel(context.Background())
+ defer cancel()
+ go watch(ctx, host, data, manifest, rules, false, false)
+
+ time.Sleep(100 * time.Millisecond)
+
+ // Create ignored files - should NOT sync
+ writeFile(t, host, "debug.log", "log data")
+ writeFile(t, host, "build/output.o", "binary")
+
+ time.Sleep(500 * time.Millisecond)
+
+ assertFileNotExists(t, data, "debug.log")
+ assertFileNotExists(t, data, "build/output.o")
+
+ // Create non-ignored file - should sync
+ writeFile(t, host, "app.go", "package main")
+ assertFileEventually(t, data, "app.go", "package main", 2*time.Second)
+}
+
+func TestWatcher_Debouncing(t *testing.T) {
+ host := t.TempDir()
+ data := t.TempDir()
+
+ rules := ignore.CompileIgnoreLines()
+ manifest, err := initialSync(host, data, rules, false)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ ctx, cancel := context.WithCancel(context.Background())
+ defer cancel()
+ go watch(ctx, host, data, manifest, rules, false, false)
+
+ time.Sleep(100 * time.Millisecond)
+
+ // Rapid writes to same file
+ writeFile(t, host, "file.txt", "v1")
+ writeFile(t, host, "file.txt", "v2")
+ writeFile(t, host, "file.txt", "v3")
+ writeFile(t, host, "file.txt", "final")
+
+ // Wait for debounce + sync
+ assertFileEventually(t, data, "file.txt", "final", 2*time.Second)
+
+ // Check that intermediate states don't matter - final content is correct
+ content, err := os.ReadFile(filepath.Join(data, "file.txt"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if string(content) != "final" {
+ t.Errorf("content = %q, want %q", string(content), "final")
+ }
+}
+
+func TestWatcher_CreateDirectory(t *testing.T) {
+ host := t.TempDir()
+ data := t.TempDir()
+
+ rules := ignore.CompileIgnoreLines()
+ manifest, err := initialSync(host, data, rules, false)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ ctx, cancel := context.WithCancel(context.Background())
+ defer cancel()
+ go watch(ctx, host, data, manifest, rules, false, false)
+
+ time.Sleep(100 * time.Millisecond)
+
+ // Create directory and file inside it
+ if err := os.MkdirAll(filepath.Join(host, "newdir"), 0755); err != nil {
+ t.Fatal(err)
+ }
+ time.Sleep(300 * time.Millisecond) // let watcher register new dir
+ writeFile(t, host, "newdir/file.txt", "nested")
+
+ assertDirExistsEventually(t, data, "newdir", 2*time.Second)
+ assertFileEventually(t, data, "newdir/file.txt", "nested", 2*time.Second)
+}
+
+func TestWatcher_RemoveDirectory(t *testing.T) {
+ host := t.TempDir()
+ data := t.TempDir()
+
+ writeFile(t, host, "dir/file.txt", "content")
+
+ rules := ignore.CompileIgnoreLines()
+ manifest, err := initialSync(host, data, rules, false)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ assertFileExists(t, data, "dir/file.txt", "content")
+
+ ctx, cancel := context.WithCancel(context.Background())
+ defer cancel()
+ go watch(ctx, host, data, manifest, rules, false, false)
+
+ time.Sleep(100 * time.Millisecond)
+
+ os.RemoveAll(filepath.Join(host, "dir"))
+ assertFileRemovedEventually(t, data, "dir/file.txt", 2*time.Second)
+}
+
+func TestWatcher_VerifyManifestUpdated(t *testing.T) {
+ host := t.TempDir()
+ data := t.TempDir()
+
+ writeFile(t, host, "file.txt", "v1")
+
+ rules := ignore.CompileIgnoreLines()
+ manifest, err := initialSync(host, data, rules, false)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ if manifest.Files["file.txt"].Size != 2 {
+ t.Errorf("initial manifest size = %d, want 2", manifest.Files["file.txt"].Size)
+ }
+
+ ctx, cancel := context.WithCancel(context.Background())
+ defer cancel()
+ go watch(ctx, host, data, manifest, rules, false, false)
+
+ time.Sleep(100 * time.Millisecond)
+
+ writeFile(t, host, "file.txt", "version two")
+
+ // Wait for sync
+ time.Sleep(500 * time.Millisecond)
+
+ if manifest.Files["file.txt"].Size != 11 {
+ t.Errorf("manifest size after write = %d, want 11", manifest.Files["file.txt"].Size)
+ }
+}
+
+// Helpers
+
+func assertFileEventually(t *testing.T, dir, path, expectedContent string, timeout time.Duration) {
+ t.Helper()
+ deadline := time.Now().Add(timeout)
+ for time.Now().Before(deadline) {
+ content, err := os.ReadFile(filepath.Join(dir, path))
+ if err == nil && string(content) == expectedContent {
+ return
+ }
+ time.Sleep(50 * time.Millisecond)
+ }
+ content, _ := os.ReadFile(filepath.Join(dir, path))
+ t.Errorf("file %s not as expected within %v: got %q, want %q", path, timeout, string(content), expectedContent)
+}
+
+func assertFileRemovedEventually(t *testing.T, dir, path string, timeout time.Duration) {
+ t.Helper()
+ deadline := time.Now().Add(timeout)
+ for time.Now().Before(deadline) {
+ _, err := os.Stat(filepath.Join(dir, path))
+ if os.IsNotExist(err) {
+ return
+ }
+ time.Sleep(50 * time.Millisecond)
+ }
+ t.Errorf("file %s still exists after %v", path, timeout)
+}
+
+func assertDirExistsEventually(t *testing.T, dir, path string, timeout time.Duration) {
+ t.Helper()
+ deadline := time.Now().Add(timeout)
+ for time.Now().Before(deadline) {
+ info, err := os.Stat(filepath.Join(dir, path))
+ if err == nil && info.IsDir() {
+ return
+ }
+ time.Sleep(50 * time.Millisecond)
+ }
+ t.Errorf("directory %s not found within %v", path, timeout)
+}