package main import ( "context" "log" "os" "path/filepath" "strings" "sync" "time" "github.com/fsnotify/fsnotify" ignore "github.com/sabhiram/go-gitignore" ) func watch(ctx context.Context, hostDir, dataDir string, manifest *Manifest, rules *ignore.GitIgnore, dryRun, verbose bool) error { watcher, err := fsnotify.NewWatcher() if err != nil { return err } defer watcher.Close() var mu sync.Mutex debounce := make(map[string]*time.Timer) if err := addWatchersRecursively(watcher, hostDir, rules); 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 relPath == ".git" || strings.HasPrefix(relPath, ".git"+string(filepath.Separator)) { continue } if rules.MatchesPath(relPath) { continue } if verbose { log.Printf("event: %s %s", event.Op, relPath) } mu.Lock() if t, exists := debounce[relPath]; exists { t.Stop() } debounce[relPath] = time.AfterFunc(200*time.Millisecond, func() { mu.Lock() delete(debounce, relPath) mu.Unlock() handleEvent(event.Op, hostDir, dataDir, relPath, manifest, rules, dryRun, verbose, watcher) }) mu.Unlock() case err, ok := <-watcher.Errors: if !ok { return nil } log.Printf("watcher error: %v", err) } } } func handleEvent(op fsnotify.Op, hostDir, dataDir, relPath string, manifest *Manifest, rules *ignore.GitIgnore, dryRun, verbose 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, dryRun, verbose, watcher) case op&fsnotify.Write != 0: syncWrite(srcPath, destPath, relPath, manifest, dryRun, verbose) case op&fsnotify.Remove != 0: syncRemove(destPath, relPath, manifest, dryRun, verbose) case op&fsnotify.Rename != 0: syncRemove(destPath, relPath, manifest, dryRun, verbose) } } func syncCreate(srcPath, destPath, relPath string, manifest *Manifest, dryRun, verbose bool, watcher *fsnotify.Watcher) { info, err := os.Lstat(srcPath) if err != nil { if os.IsNotExist(err) { return } log.Printf("error stat %s: %v", relPath, err) return } if info.IsDir() { if err := watcher.Add(srcPath); err != nil { log.Printf("error adding watcher for %s: %v", relPath, err) } if dryRun { log.Printf("[dry-run] would create dir: %s", relPath) return } if err := os.MkdirAll(destPath, info.Mode()); err != nil { log.Printf("error creating dir %s: %v", relPath, err) } return } linkTarget := "" if info.Mode()&os.ModeSymlink != 0 { linkTarget, _ = os.Readlink(srcPath) } meta := FileMeta{ Size: info.Size(), Mode: info.Mode(), Symlink: linkTarget, } manifest.Files[relPath] = meta if dryRun { log.Printf("[dry-run] would sync: %s", relPath) return } os.MkdirAll(filepath.Dir(destPath), 0755) if linkTarget != "" { if err := os.Symlink(linkTarget, destPath); err != nil { log.Printf("error creating symlink %s: %v", relPath, err) } return } if err := copyFile(srcPath, destPath, info.Mode()); err != nil { log.Printf("error copying %s: %v", relPath, err) } } func syncWrite(srcPath, destPath, relPath string, manifest *Manifest, dryRun, verbose bool) { info, err := os.Lstat(srcPath) if err != nil { if os.IsNotExist(err) { return } log.Printf("error stat %s: %v", relPath, err) return } meta, exists := manifest.Files[relPath] if exists && meta.Size == info.Size() && meta.Mode == info.Mode() { return } manifest.Files[relPath] = FileMeta{ Size: info.Size(), Mode: info.Mode(), } if dryRun { log.Printf("[dry-run] would update: %s", relPath) return } if err := copyFile(srcPath, destPath, info.Mode()); err != nil { log.Printf("error copying %s: %v", relPath, err) } } func syncRemove(destPath, relPath string, manifest *Manifest, dryRun, verbose bool) { delete(manifest.Files, relPath) if dryRun { log.Printf("[dry-run] would remove: %s", relPath) return } os.Remove(destPath) } func addWatchersRecursively(watcher *fsnotify.Watcher, root string, rules *ignore.GitIgnore) 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 relPath == ".git" || strings.HasPrefix(relPath, ".git"+string(filepath.Separator)) { return filepath.SkipDir } if info.IsDir() && !rules.MatchesPath(relPath) { return watcher.Add(path) } return nil }) }