aboutsummaryrefslogtreecommitdiffstats
path: root/cmd_windows.go
diff options
context:
space:
mode:
authorBernhard Guillon <Bernhard.Guillon@begu.org>2026-07-07 09:03:31 +0200
committerBernhard Guillon <Bernhard.Guillon@begu.org>2026-07-07 09:03:31 +0200
commita0d157d45c5b4c63afc2cfc82bde3ae3207bc455 (patch)
treec6152cf56c437945359a3b80fc16b76e9ba7332a /cmd_windows.go
parente4c2f78abe427d9fdbfb46bb205f2210a03704fa (diff)
downloadsourcewatch-a0d157d45c5b4c63afc2cfc82bde3ae3207bc455.tar.gz
sourcewatch-a0d157d45c5b4c63afc2cfc82bde3ae3207bc455.zip
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
Diffstat (limited to 'cmd_windows.go')
-rw-r--r--cmd_windows.go250
1 files changed, 250 insertions, 0 deletions
diff --git a/cmd_windows.go b/cmd_windows.go
new file mode 100644
index 0000000..b68851a
--- /dev/null
+++ b/cmd_windows.go
@@ -0,0 +1,250 @@
+//go:build windows
+
+package main
+
+import (
+ "context"
+ "encoding/json"
+ "flag"
+ "fmt"
+ "net"
+ "os"
+ "os/signal"
+ "path/filepath"
+ "sync"
+ "syscall"
+ "time"
+
+ "github.com/fsnotify/fsnotify"
+)
+
+var (
+ socketPath = flag.String("socket", "", "Unix socket path to serve events on (required)")
+ sourceDir = flag.String("dir", "", "Source directory to watch (required)")
+)
+
+func run() {
+ flag.Parse()
+
+ if *socketPath == "" || *sourceDir == "" {
+ fmt.Fprintln(os.Stderr, "Usage: sourcewatch.exe -dir <source> -socket <socket-path>")
+ os.Exit(1)
+ }
+
+ level, err := ParseLogLevel(*logLevel)
+ if err != nil {
+ fmt.Fprintln(os.Stderr, err)
+ os.Exit(1)
+ }
+ SetLogLevel(level)
+
+ logInfo("watcher: starting on %s", *sourceDir)
+ logInfo("watcher: serving on %s", *socketPath)
+
+ stack := NewGitignoreStack(*sourceDir)
+
+ watcher, err := fsnotify.NewWatcher()
+ if err != nil {
+ logErrorf("watcher: %v", err)
+ os.Exit(1)
+ }
+ defer watcher.Close()
+
+ if err := addWatchersRecursively(watcher, *sourceDir, stack); err != nil {
+ logErrorf("watcher: %v", err)
+ os.Exit(1)
+ }
+
+ os.Remove(*socketPath)
+
+ ln, err := net.Listen("unix", *socketPath)
+ if err != nil {
+ logErrorf("socket: %v", err)
+ os.Exit(1)
+ }
+ defer func() {
+ ln.Close()
+ os.Remove(*socketPath)
+ }()
+
+ var mu sync.Mutex
+ debounceTimers := make(map[string]*time.Timer)
+ debounceOps := make(map[string]fsnotify.Op)
+
+ clients := make(map[net.Conn]bool)
+ var clientsMu sync.Mutex
+
+ go func() {
+ for {
+ conn, err := ln.Accept()
+ if err != nil {
+ return
+ }
+ clientsMu.Lock()
+ clients[conn] = true
+ clientsMu.Unlock()
+ logDebug("client connected: %s", conn.RemoteAddr())
+
+ go func(c net.Conn) {
+ defer func() {
+ clientsMu.Lock()
+ delete(clients, c)
+ clientsMu.Unlock()
+ c.Close()
+ }()
+ buf := make([]byte, 1)
+ for {
+ if _, err := c.Read(buf); err != nil {
+ return
+ }
+ }
+ }(conn)
+ }
+ }()
+
+ broadcast := func(evt FileEvent) {
+ data, err := json.Marshal(evt)
+ if err != nil {
+ return
+ }
+ data = append(data, '\n')
+
+ clientsMu.Lock()
+ defer clientsMu.Unlock()
+ for c := range clients {
+ if _, err := c.Write(data); err != nil {
+ go func() {
+ clientsMu.Lock()
+ delete(clients, c)
+ clientsMu.Unlock()
+ c.Close()
+ }()
+ }
+ }
+ }
+
+ ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
+ defer cancel()
+
+ go func() {
+ <-ctx.Done()
+ ln.Close()
+ }()
+
+ for {
+ select {
+ case <-ctx.Done():
+ return
+
+ case event, ok := <-watcher.Events:
+ if !ok {
+ return
+ }
+
+ relPath, err := filepath.Rel(*sourceDir, event.Name)
+ if err != nil {
+ continue
+ }
+
+ if isGitDir(relPath) {
+ continue
+ }
+
+ if stack.IsIgnored(relPath) {
+ continue
+ }
+
+ logDebug("event: %s %s", event.Op, relPath)
+
+ if event.Op&fsnotify.Create != 0 {
+ srcPath := filepath.Join(*sourceDir, relPath)
+ info, err := os.Lstat(srcPath)
+ if err == nil && info.IsDir() {
+ if hasGitignore(srcPath) {
+ stack.Push(srcPath)
+ }
+ if err := watcher.Add(srcPath); err != nil {
+ logErrorf("watch %s: %v", relPath, err)
+ }
+ 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()
+
+ evt := FileEvent{Path: path}
+ switch {
+ case op&fsnotify.Create != 0:
+ evt.Op = "create"
+ case op&fsnotify.Write != 0:
+ evt.Op = "write"
+ case op&fsnotify.Remove != 0:
+ evt.Op = "remove"
+ case op&fsnotify.Rename != 0:
+ evt.Op = "remove"
+ default:
+ return
+ }
+
+ broadcast(evt)
+ })
+ mu.Unlock()
+
+ case err, ok := <-watcher.Errors:
+ if !ok {
+ return
+ }
+ logErrorf("watcher: %v", err)
+ }
+ }
+}
+
+func addWatchersRecursively(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
+ })
+}