aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--README.md57
-rw-r--r--cmd_linux.go55
-rw-r--r--cmd_windows.go105
3 files changed, 101 insertions, 116 deletions
diff --git a/README.md b/README.md
index d0bcd01..0fd0e23 100644
--- a/README.md
+++ b/README.md
@@ -7,17 +7,18 @@ A file synchronization tool that watches a Windows source directory and syncs ch
```
Windows Host Docker Container
┌─────────────────────┐ ┌─────────────────────────┐
-│ sourcewatch.exe │ │ sourcewatch (linux) │
-│ watches C:\src │ AF_UNIX │ - receives manifest │
-│ ReadDirectory │◄────────────►│ - syncs changed files │
-│ ChangesW │ socket │ - listens for events │
+│ sourcewatch.exe │ TCP │ sourcewatch (linux) │
+│ watches C:\src │◄────────────►│ - receives manifest │
+│ ReadDirectory │ connect │ - syncs changed files │
+│ ChangesW │ :5151 │ - listens for events │
└─────────────────────┘ └─────────────────────────┘
```
-1. **Windows binary** scans source directory (fast native NTFS) and sends file metadata
-2. **Container** compares with local state, copies only changed files
-3. **Windows binary** watches for changes, sends events over socket
-4. **Container** receives events and syncs in real-time
+1. **Container** starts and listens on TCP port 5151
+2. **Windows binary** connects to container, scans source directory (fast native NTFS), sends file metadata
+3. **Container** compares with local state, copies only changed files
+4. **Windows binary** watches for changes, sends events over TCP
+5. **Container** receives events and syncs in real-time
## Building
@@ -51,21 +52,10 @@ podman build -t sourcewatch .
GOOS=windows GOARCH=amd64 go build -o sourcewatch.exe .
```
-### Step 2: Start the Windows watcher
+### Step 2: Start the container
```bash
-# PowerShell
-.\sourcewatch.exe -dir C:\myproject -socket D:\sourcewatch.sock
-
-# Git Bash
-./sourcewatch.exe -dir /c/myproject -socket /d/sourcewatch.sock
-```
-
-### Step 3: Start the container
-
-```bash
-docker run -d \
- -v D:\sourcewatch.sock:/run/sourcewatch.sock \
+docker run -d -p 5151:5151 \
-v C:\myproject:/host:ro \
-v project-data:/data \
sourcewatch
@@ -74,19 +64,28 @@ docker run -d \
Or with Podman:
```bash
-podman run -d \
- -v D:\sourcewatch.sock:/run/sourcewatch.sock \
+podman run -d -p 5151:5151 \
-v C:\myproject:/host:ro \
-v project-data:/data \
sourcewatch
```
+### Step 3: Start the Windows watcher
+
+```powershell
+# PowerShell
+.\sourcewatch.exe -dir C:\myproject -connect localhost:5151
+
+# Git Bash
+./sourcewatch.exe -dir /c/myproject -connect localhost:5151
+```
+
### Standalone mode (no Windows watcher)
-If you don't need real-time watching, run without `-socket`:
+If you don't need real-time watching, run with `-listen ""`:
```bash
-docker run -v /path/to/source:/host:ro -v data:/data sourcewatch
+docker run -v /path/to/source:/host:ro -v data:/data sourcewatch -listen ""
```
## Flags
@@ -95,7 +94,7 @@ docker run -v /path/to/source:/host:ro -v data:/data sourcewatch
```
-dir string Source directory to watch (required)
--socket string Unix socket path to serve events on (required)
+-connect string TCP address of the container (default "localhost:5151")
-log-level string Log level: debug, info, warn, error, none (default "info")
-verbose Enable verbose logging (shorthand for -log-level debug)
```
@@ -105,7 +104,7 @@ docker run -v /path/to/source:/host:ro -v data:/data sourcewatch
```
-host string Host source directory (default "/host")
-data string Data destination directory (default "/data")
--socket string Unix socket to receive events from (default "/run/sourcewatch.sock")
+-listen string TCP address to listen on (default ":5151")
-verbose Enable verbose logging (shorthand for -log-level debug)
-log-level string Log level: debug, info, warn, error, none (default "info")
-dry-run Preview what would be synced without copying
@@ -113,7 +112,7 @@ docker run -v /path/to/source:/host:ro -v data:/data sourcewatch
## Protocol
-Communication uses newline-delimited JSON over Unix socket:
+Communication uses newline-delimited JSON over TCP:
```json
{"type":"manifest","files":{"path":{"size":1234,"modtime":"2024-01-01T00:00:00Z","mode":"0644"}}}
@@ -142,7 +141,7 @@ build/
- One-way sync only (`/host` → `/data`)
- `/host` must be read-only
- `?` single-character wildcard not supported (go-gitignore limitation)
-- Windows binary requires Go 1.24+ to build
+- Windows binary requires Go 1.21+ to build
## License
diff --git a/cmd_linux.go b/cmd_linux.go
index 65e8644..069881a 100644
--- a/cmd_linux.go
+++ b/cmd_linux.go
@@ -15,10 +15,10 @@ import (
)
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")
- socketPath = flag.String("socket", "/run/sourcewatch.sock", "Unix socket to receive events from (Windows watcher)")
+ 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() {
@@ -40,8 +40,8 @@ func run() {
stack := NewGitignoreStack(*hostDir)
- if *socketPath == "" {
- logInfo("no -socket specified, running standalone (full scan)")
+ 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)
@@ -57,29 +57,35 @@ func run() {
return
}
- logInfo("watcher: connecting to %s ...", *socketPath)
+ 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 ...")
- var conn net.Conn
for {
if ctx.Err() != nil {
- logErrorf("cancelled while connecting")
- os.Exit(1)
- }
- conn, err = net.Dial("unix", *socketPath)
- if err == nil {
- break
+ return
}
- logDebug("connect failed: %v, retrying...", err)
- select {
- case <-ctx.Done():
- logErrorf("cancelled while connecting")
- os.Exit(1)
- case <-time.After(1 * time.Second):
+
+ 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 ...")
}
- defer conn.Close()
+}
- logInfo("watcher: connected, receiving manifest ...")
+func handleClient(conn net.Conn, stack *GitignoreStack) {
+ defer conn.Close()
scanner := bufio.NewScanner(conn)
remoteManifest := &Manifest{Files: make(map[string]FileMeta)}
@@ -134,7 +140,7 @@ func run() {
}
if err := scanner.Err(); err != nil {
- logErrorf("socket read: %v", err)
+ logErrorf("read: %v", err)
}
}
@@ -142,7 +148,6 @@ func syncFromManifest(remote *Manifest, stack *GitignoreStack) {
stats := &syncStats{}
start := time.Now()
- // Scan local /data to get current state (fast, local volume)
localManifest := scanLocalDirectory(*dataDir)
for relPath, remoteMeta := range remote.Files {
@@ -157,7 +162,6 @@ func syncFromManifest(remote *Manifest, stack *GitignoreStack) {
destPath := filepath.Join(*dataDir, relPath)
srcPath := filepath.Join(*hostDir, relPath)
- // Check if file already exists locally with same metadata
if localMeta, exists := localManifest.Files[relPath]; exists {
if localMeta.Size == remoteMeta.Size &&
localMeta.Mode == remoteMeta.Mode &&
@@ -167,7 +171,6 @@ func syncFromManifest(remote *Manifest, stack *GitignoreStack) {
}
}
- // Need to sync
info, err := os.Lstat(srcPath)
if err != nil {
if os.IsNotExist(err) {
diff --git a/cmd_windows.go b/cmd_windows.go
index 61c7f1d..7df0a9b 100644
--- a/cmd_windows.go
+++ b/cmd_windows.go
@@ -19,15 +19,15 @@ import (
)
var (
- socketPath = flag.String("socket", "", "Unix socket path to serve events on (required)")
- sourceDir = flag.String("dir", "", "Source directory to watch (required)")
+ sourceDir = flag.String("dir", "", "Source directory to watch (required)")
+ connect = flag.String("connect", "localhost:5151", "TCP address of the container to connect to")
)
func run() {
flag.Parse()
- if *socketPath == "" || *sourceDir == "" {
- fmt.Fprintln(os.Stderr, "Usage: sourcewatch.exe -dir <source> -socket <socket-path>")
+ if *sourceDir == "" {
+ fmt.Fprintln(os.Stderr, "Usage: sourcewatch.exe -dir <source> [-connect host:port]")
os.Exit(1)
}
@@ -43,7 +43,7 @@ func run() {
stack := NewGitignoreStack(*sourceDir)
manifest := scanDirectory(*sourceDir, stack)
- logInfo("watcher: found %d files, serving on %s", len(manifest.Files), *socketPath)
+ logInfo("watcher: found %d files", len(manifest.Files))
watcher, err := fsnotify.NewWatcher()
if err != nil {
@@ -57,75 +57,61 @@ func run() {
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)
- }()
+ ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
+ defer cancel()
- var mu sync.Mutex
- debounceTimers := make(map[string]*time.Timer)
- debounceOps := make(map[string]fsnotify.Op)
+ for {
+ if ctx.Err() != nil {
+ return
+ }
- clients := make(map[net.Conn]bool)
- var clientsMu sync.Mutex
+ logInfo("watcher: connecting to %s ...", *connect)
- go func() {
+ var conn net.Conn
for {
- conn, err := ln.Accept()
- if err != nil {
+ if ctx.Err() != nil {
+ return
+ }
+ conn, err = net.DialTimeout("tcp", *connect, 5*time.Second)
+ if err == nil {
+ break
+ }
+ logDebug("connect failed: %v, retrying...", err)
+ select {
+ case <-ctx.Done():
return
+ case <-time.After(2 * time.Second):
}
- clientsMu.Lock()
- clients[conn] = true
- clientsMu.Unlock()
- logDebug("client connected: %s", conn.RemoteAddr())
+ }
- go func(c net.Conn) {
- sendManifest(c, manifest)
+ logInfo("watcher: connected, sending manifest ...")
- scanner := bufio.NewScanner(c)
- for scanner.Scan() {
- }
+ sendManifest(conn, manifest)
- clientsMu.Lock()
- delete(clients, c)
- clientsMu.Unlock()
- c.Close()
- }(conn)
- }
- }()
+ logInfo("watcher: monitoring for changes ...")
- broadcast := func(evt FileEvent) {
- clientsMu.Lock()
- defer clientsMu.Unlock()
- for c := range clients {
- sendEvent(c, evt)
+ if !watchLoop(ctx, conn, watcher, manifest, stack) {
+ conn.Close()
+ continue
}
+ conn.Close()
+ return
}
+}
- ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
- defer cancel()
-
- go func() {
- <-ctx.Done()
- ln.Close()
- }()
+func watchLoop(ctx context.Context, conn net.Conn, watcher *fsnotify.Watcher, manifest *Manifest, stack *GitignoreStack) bool {
+ var mu sync.Mutex
+ debounceTimers := make(map[string]*time.Timer)
+ debounceOps := make(map[string]fsnotify.Op)
for {
select {
case <-ctx.Done():
- return
+ return true
case event, ok := <-watcher.Events:
if !ok {
- return
+ return true
}
relPath, err := filepath.Rel(*sourceDir, event.Name)
@@ -192,7 +178,6 @@ func run() {
return
}
- // Update manifest
srcPath := filepath.Join(*sourceDir, path)
switch evt.Op {
case "create":
@@ -225,15 +210,16 @@ func run() {
delete(manifest.Files, path)
}
- broadcast(evt)
+ sendEvent(conn, evt)
})
mu.Unlock()
case err, ok := <-watcher.Errors:
if !ok {
- return
+ return true
}
logErrorf("watcher: %v", err)
+ return false
}
}
}
@@ -307,10 +293,7 @@ func sendManifest(conn net.Conn, manifest *Manifest) {
}
func sendEvent(conn net.Conn, evt FileEvent) {
- msg := &ProtocolMessage{
- Type: "event",
- Path: evt.Path,
- }
+ msg := &ProtocolMessage{Path: evt.Path}
switch evt.Op {
case "create":
msg.Type = "event_create"