//go:build !windows package main import ( "bufio" "context" "flag" "net" "os" "os/signal" "path/filepath" "syscall" "time" ) var ( hostDir = flag.String("host", "/host", "Host source directory (read-only)") dataDir = flag.String("data", "/data", "Data destination directory") dryRun = flag.Bool("dry-run", false, "Preview what would be synced without copying") listen = flag.String("listen", ":5151", "TCP address to listen on for Windows watcher connections") ) func run() { flag.Parse() if *verbose { *logLevel = "debug" } level, err := ParseLogLevel(*logLevel) if err != nil { logErrorf("%v", err) os.Exit(1) } SetLogLevel(level) ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) defer cancel() stack := NewGitignoreStack(*hostDir) if *listen == "" { logInfo("no -listen specified, running standalone (full scan)") manifest, err := initialSync(*hostDir, *dataDir, stack, *dryRun) if err != nil { logErrorf("initial sync failed: %v", err) os.Exit(1) } if *dryRun { logInfo("dry-run complete, exiting") return } logInfo("watcher: ready (no file watching in standalone mode)") _ = manifest <-ctx.Done() return } ln, err := net.Listen("tcp", *listen) if err != nil { logErrorf("listen: %v", err) os.Exit(1) } defer ln.Close() logInfo("watcher: listening on %s", *listen) logInfo("watcher: waiting for Windows watcher to connect ...") for { if ctx.Err() != nil { return } conn, err := ln.Accept() if err != nil { logErrorf("accept: %v", err) continue } logInfo("watcher: client connected from %s", conn.RemoteAddr()) handleClient(conn, stack) logInfo("watcher: client disconnected, waiting for reconnect ...") } } func handleClient(conn net.Conn, stack *GitignoreStack) { defer conn.Close() scanner := bufio.NewScanner(conn) remoteManifest := &Manifest{Files: make(map[string]FileMeta)} for scanner.Scan() { msg, err := DecodeMessage(scanner.Bytes()) if err != nil { logDebug("bad message: %v", err) continue } switch msg.Type { case "manifest": remoteManifest = ManifestFromJSON(msg.Files) logInfo("manifest: received %d files from host", len(remoteManifest.Files)) case "manifest_done": logInfo("manifest: comparing with local state ...") syncFromManifest(remoteManifest, stack) logInfo("sync: done") logInfo("watcher: monitoring for changes ...") case "event_create": logDebug("event: create %s", msg.Path) if !isGitDir(msg.Path) && !stack.IsIgnored(msg.Path) { syncCreate( filepath.Join(*hostDir, msg.Path), filepath.Join(*dataDir, msg.Path), msg.Path, remoteManifest, stack, *dryRun, nil, ) } case "event_write": logDebug("event: write %s", msg.Path) if !isGitDir(msg.Path) && !stack.IsIgnored(msg.Path) { syncWrite( filepath.Join(*hostDir, msg.Path), filepath.Join(*dataDir, msg.Path), msg.Path, remoteManifest, *dryRun, ) } case "event_remove": logDebug("event: remove %s", msg.Path) if !isGitDir(msg.Path) && !stack.IsIgnored(msg.Path) { syncRemove( filepath.Join(*dataDir, msg.Path), msg.Path, remoteManifest, *dryRun, ) } } } if err := scanner.Err(); err != nil { logErrorf("read: %v", err) } } func syncFromManifest(remote *Manifest, stack *GitignoreStack) { stats := &syncStats{} start := time.Now() localManifest := scanLocalDirectory(*dataDir) for relPath, remoteMeta := range remote.Files { if isGitDir(relPath) { continue } if stack.IsIgnored(relPath) { stats.skipped++ continue } destPath := filepath.Join(*dataDir, relPath) srcPath := filepath.Join(*hostDir, relPath) if localMeta, exists := localManifest.Files[relPath]; exists { if localMeta.Size == remoteMeta.Size && localMeta.Mode == remoteMeta.Mode && localMeta.ModTime.Equal(remoteMeta.ModTime) && localMeta.Symlink == remoteMeta.Symlink { continue } } info, err := os.Lstat(srcPath) if err != nil { if os.IsNotExist(err) { continue } stats.errors++ logErrorf("stat %s: %v", relPath, err) continue } if info.IsDir() { stats.dirs++ if *dryRun { logInfo("[dry-run] would create dir: %s", relPath) continue } if err := os.MkdirAll(destPath, info.Mode()); err != nil { stats.errors++ logErrorf("mkdir %s: %v", relPath, err) } continue } stats.files++ if *dryRun { logInfo("[dry-run] would sync: %s", relPath) continue } os.MkdirAll(filepath.Dir(destPath), 0755) os.Remove(destPath) linkTarget := "" if info.Mode()&os.ModeSymlink != 0 { linkTarget, err = os.Readlink(srcPath) if err != nil { stats.errors++ logErrorf("readlink %s: %v", relPath, err) continue } if err := os.Symlink(linkTarget, destPath); err != nil { stats.errors++ logErrorf("symlink %s: %v", relPath, err) } continue } if err := copyFileWithRetry(srcPath, destPath, info.Mode()); err != nil { stats.errors++ logErrorf("copy %s: %v", relPath, err) } if stats.files%1000 == 0 && stats.files > 0 { logInfo(" synced %d files ...", stats.files) } } elapsed := time.Since(start) logInfo("sync: done in %s — %d files, %d dirs, %d skipped, %d errors", elapsed.Round(time.Millisecond), stats.files, stats.dirs, stats.skipped, stats.errors) } func scanLocalDirectory(root string) *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 } 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 }