//go:build windows package main import ( "bufio" "context" "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 -socket ") os.Exit(1) } level, err := ParseLogLevel(*logLevel) if err != nil { fmt.Fprintln(os.Stderr, err) os.Exit(1) } SetLogLevel(level) logInfo("watcher: scanning %s ...", *sourceDir) stack := NewGitignoreStack(*sourceDir) manifest := scanDirectory(*sourceDir, stack) logInfo("watcher: found %d files, serving on %s", len(manifest.Files), *socketPath) 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) { sendManifest(c, manifest) scanner := bufio.NewScanner(c) for scanner.Scan() { } clientsMu.Lock() delete(clients, c) clientsMu.Unlock() c.Close() }(conn) } }() broadcast := func(evt FileEvent) { clientsMu.Lock() defer clientsMu.Unlock() for c := range clients { sendEvent(c, evt) } } 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 } // Update manifest srcPath := filepath.Join(*sourceDir, path) switch evt.Op { case "create": if info, err := os.Lstat(srcPath); err == nil { linkTarget := "" if info.Mode()&os.ModeSymlink != 0 { linkTarget, _ = os.Readlink(srcPath) } manifest.Files[path] = FileMeta{ Size: info.Size(), Mode: info.Mode(), ModTime: info.ModTime(), Symlink: linkTarget, } } case "write": if info, err := os.Lstat(srcPath); err == nil { linkTarget := "" if info.Mode()&os.ModeSymlink != 0 { linkTarget, _ = os.Readlink(srcPath) } manifest.Files[path] = FileMeta{ Size: info.Size(), Mode: info.Mode(), ModTime: info.ModTime(), Symlink: linkTarget, } } case "remove": delete(manifest.Files, path) } broadcast(evt) }) mu.Unlock() case err, ok := <-watcher.Errors: if !ok { return } logErrorf("watcher: %v", err) } } } func scanDirectory(root string, stack *GitignoreStack) *Manifest { manifest := &Manifest{Files: make(map[string]FileMeta)} filepath.Walk(root, func(path string, info os.FileInfo, err error) error { if err != nil { return nil } relPath, err := filepath.Rel(root, path) if err != nil { return nil } if relPath == "." { return nil } if isGitDir(relPath) { if info.IsDir() { return filepath.SkipDir } return nil } if info.IsDir() && hasGitignore(path) { stack.Push(path) } if stack.IsIgnored(relPath) { return nil } linkTarget := "" if info.Mode()&os.ModeSymlink != 0 { linkTarget, _ = os.Readlink(path) } manifest.Files[relPath] = FileMeta{ Size: info.Size(), Mode: info.Mode(), ModTime: info.ModTime(), Symlink: linkTarget, } return nil }) return manifest } func sendManifest(conn net.Conn, manifest *Manifest) { msg := &ProtocolMessage{ Type: "manifest", Files: manifest.ToJSON(), } data, err := EncodeMessage(msg) if err != nil { return } data = append(data, '\n') conn.Write(data) done := &ProtocolMessage{Type: "manifest_done"} doneData, _ := EncodeMessage(done) doneData = append(doneData, '\n') conn.Write(doneData) } func sendEvent(conn net.Conn, evt FileEvent) { msg := &ProtocolMessage{ Type: "event", Path: evt.Path, } switch evt.Op { case "create": msg.Type = "event_create" case "write": msg.Type = "event_write" case "remove": msg.Type = "event_remove" } data, err := EncodeMessage(msg) if err != nil { return } data = append(data, '\n') conn.Write(data) } 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 }) }