package main import ( "context" "os" "path/filepath" "sync" "time" "github.com/fsnotify/fsnotify" ) func watch(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 := addWatchersRecursively(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 } logDebug("event: %s %s", event.Op, relPath) 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() handleEvent(op, hostDir, dataDir, path, manifest, stack, dryRun, watcher) }) mu.Unlock() case err, ok := <-watcher.Errors: if !ok { return nil } logErrorf("watcher: %v", err) } } } func handleEvent(op fsnotify.Op, hostDir, dataDir, relPath string, manifest *Manifest, stack *GitignoreStack, dryRun bool, watcher *fsnotify.Watcher) { destPath := filepath.Join(dataDir, relPath) srcPath := filepath.Join(hostDir, relPath) switch { case op&fsnotify.Create != 0: syncCreate(srcPath, destPath, relPath, manifest, stack, dryRun, watcher) case op&fsnotify.Write != 0: syncWrite(srcPath, destPath, relPath, manifest, dryRun) case op&fsnotify.Remove != 0: syncRemove(destPath, relPath, manifest, dryRun) case op&fsnotify.Rename != 0: syncRemove(destPath, relPath, manifest, dryRun) } } func syncCreate(srcPath, destPath, relPath string, manifest *Manifest, stack *GitignoreStack, dryRun bool, watcher *fsnotify.Watcher) { info, err := os.Lstat(srcPath) if err != nil { if os.IsNotExist(err) { return } logErrorf("stat %s: %v", relPath, err) return } if 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 }) if dryRun { logInfo("[dry-run] would create dir: %s", relPath) return } if err := os.MkdirAll(destPath, info.Mode()); err != nil { logErrorf("mkdir %s: %v", relPath, err) } return } linkTarget := "" if info.Mode()&os.ModeSymlink != 0 { linkTarget, err = os.Readlink(srcPath) if err != nil { logErrorf("readlink %s: %v", relPath, err) return } } meta := FileMeta{ Size: info.Size(), Mode: info.Mode(), ModTime: info.ModTime(), Symlink: linkTarget, } manifest.Files[relPath] = meta if dryRun { logInfo("[dry-run] would sync: %s", relPath) return } os.MkdirAll(filepath.Dir(destPath), 0755) os.Remove(destPath) if linkTarget != "" { if err := os.Symlink(linkTarget, destPath); err != nil { logErrorf("symlink %s: %v", relPath, err) } logInfo("sync: %s -> %s (symlink)", relPath, linkTarget) return } if err := copyFileWithRetry(srcPath, destPath, info.Mode()); err != nil { logErrorf("copy %s: %v", relPath, err) } else { logInfo("sync: %s", relPath) } } func syncWrite(srcPath, destPath, relPath string, manifest *Manifest, dryRun bool) { info, err := os.Lstat(srcPath) if err != nil { if os.IsNotExist(err) { return } logErrorf("stat %s: %v", relPath, err) return } meta, exists := manifest.Files[relPath] if exists && meta.Size == info.Size() && meta.Mode == info.Mode() && meta.ModTime.Equal(info.ModTime()) { return } linkTarget := "" if info.Mode()&os.ModeSymlink != 0 { linkTarget, err = os.Readlink(srcPath) if err != nil { logErrorf("readlink %s: %v", relPath, err) return } } manifest.Files[relPath] = FileMeta{ Size: info.Size(), Mode: info.Mode(), ModTime: info.ModTime(), Symlink: linkTarget, } if dryRun { logInfo("[dry-run] would update: %s", relPath) return } os.Remove(destPath) if linkTarget != "" { if err := os.Symlink(linkTarget, destPath); err != nil { logErrorf("symlink %s: %v", relPath, err) } logInfo("sync: %s -> %s (symlink)", relPath, linkTarget) return } if err := copyFileWithRetry(srcPath, destPath, info.Mode()); err != nil { logErrorf("copy %s: %v", relPath, err) } else { logInfo("sync: %s", relPath) } } func syncRemove(destPath, relPath string, manifest *Manifest, dryRun bool) { delete(manifest.Files, relPath) if dryRun { logInfo("[dry-run] would remove: %s", relPath) return } if err := os.Remove(destPath); err != nil && !os.IsNotExist(err) { logErrorf("remove %s: %v", relPath, err) } else { logInfo("remove: %s", relPath) } } 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 }) }