From a0d157d45c5b4c63afc2cfc82bde3ae3207bc455 Mon Sep 17 00:00:00 2001 From: Bernhard Guillon Date: Tue, 7 Jul 2026 09:03:31 +0200 Subject: Add Windows native watcher with Unix socket IPC Architecture: - Windows binary: watches files via ReadDirectoryChangesW (fsnotify), sends events as newline-delimited JSON over Unix domain socket - Container: performs initial sync, then listens on socket for events from Windows watcher New files: - cmd_windows.go: Windows serve command (watcher + socket server) - cmd_linux.go: Linux socket client + event listener - types.go: shared FileEvent, FileMeta, Manifest types - watcher_test_helper.go: inotify-based watcher for integration tests Changes: - main.go: simplified to just call run() - watcher.go: removed fsnotify dependency, extracted Watcher interface - sync.go: removed duplicate type definitions - watcher_test.go: uses watchForTests() helper Usage: sourcewatch.exe -dir C:\myproject -socket D:\sourcewatch.sock docker run -v D:\sourcewatch.sock:/run/sourcewatch.sock \ -v C:\myproject:/host:ro -v mydata:/data sourcewatch --- watcher_test_helper.go | 142 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 142 insertions(+) create mode 100644 watcher_test_helper.go (limited to 'watcher_test_helper.go') diff --git a/watcher_test_helper.go b/watcher_test_helper.go new file mode 100644 index 0000000..519d3f6 --- /dev/null +++ b/watcher_test_helper.go @@ -0,0 +1,142 @@ +//go:build !windows + +package main + +import ( + "context" + "os" + "path/filepath" + "sync" + "time" + + "github.com/fsnotify/fsnotify" +) + +// watchForTests uses inotify directly for integration testing. +// This is only used in tests - production uses socket-based events from Windows watcher. +func watchForTests(ctx context.Context, hostDir, dataDir string, manifest *Manifest, stack *GitignoreStack, dryRun bool) error { + watcher, err := fsnotify.NewWatcher() + if err != nil { + return err + } + defer watcher.Close() + + var mu sync.Mutex + debounceTimers := make(map[string]*time.Timer) + debounceOps := make(map[string]fsnotify.Op) + + if err := addWatchersForTests(watcher, hostDir, stack); err != nil { + return err + } + + for { + select { + case <-ctx.Done(): + return nil + + case event, ok := <-watcher.Events: + if !ok { + return nil + } + + relPath, err := filepath.Rel(hostDir, event.Name) + if err != nil { + continue + } + + if isGitDir(relPath) { + continue + } + + if stack.IsIgnored(relPath) { + continue + } + + if event.Op&fsnotify.Create != 0 { + srcPath := filepath.Join(hostDir, relPath) + info, err := os.Lstat(srcPath) + if err == nil && info.IsDir() { + if hasGitignore(srcPath) { + stack.Push(srcPath) + } + watcher.Add(srcPath) + filepath.Walk(srcPath, func(path string, info os.FileInfo, err error) error { + if err != nil || !info.IsDir() || path == srcPath { + return nil + } + watcher.Add(path) + return nil + }) + } + } + + mu.Lock() + if existing, exists := debounceOps[relPath]; exists { + debounceTimers[relPath].Stop() + debounceOps[relPath] = existing | event.Op + } else { + debounceOps[relPath] = event.Op + } + path := relPath + debounceTimers[relPath] = time.AfterFunc(200*time.Millisecond, func() { + mu.Lock() + op := debounceOps[path] + delete(debounceOps, path) + delete(debounceTimers, path) + mu.Unlock() + + destPath := filepath.Join(dataDir, path) + srcPath := filepath.Join(hostDir, path) + + switch { + case op&fsnotify.Create != 0: + syncCreate(srcPath, destPath, path, manifest, stack, dryRun, watcher) + case op&fsnotify.Write != 0: + syncWrite(srcPath, destPath, path, manifest, dryRun) + case op&fsnotify.Remove != 0: + syncRemove(destPath, path, manifest, dryRun) + case op&fsnotify.Rename != 0: + syncRemove(destPath, path, manifest, dryRun) + } + }) + mu.Unlock() + + case err, ok := <-watcher.Errors: + if !ok { + return nil + } + _ = err + } + } +} + +func addWatchersForTests(watcher *fsnotify.Watcher, root string, stack *GitignoreStack) error { + return filepath.Walk(root, func(path string, info os.FileInfo, err error) error { + if err != nil { + return err + } + + relPath, err := filepath.Rel(root, path) + if err != nil { + return err + } + + if isGitDir(relPath) { + if info.IsDir() { + return filepath.SkipDir + } + return nil + } + + if info.IsDir() { + if hasGitignore(path) { + stack.Push(path) + } + if !stack.IsIgnored(relPath) { + return watcher.Add(path) + } + } + + return nil + }) +} -- cgit v1.2.3