aboutsummaryrefslogtreecommitdiffstats
path: root/watcher.go
diff options
context:
space:
mode:
authorBernhard Guillon <Bernhard.Guillon@begu.org>2026-07-06 21:15:17 +0200
committerBernhard Guillon <Bernhard.Guillon@begu.org>2026-07-06 21:15:17 +0200
commitb505ac2fe010fcc24b774c313d33dc099214e8ed (patch)
treeff3f6472c84abd45e7918722e70ee681b859b780 /watcher.go
downloadsourcewatch-b505ac2fe010fcc24b774c313d33dc099214e8ed.tar.gz
sourcewatch-b505ac2fe010fcc24b774c313d33dc099214e8ed.zip
Initial project scaffolding
- main.go: CLI entry point with flag parsing - gitignore.go: .gitignore loading and parsing - sync.go: initial sync with manifest tracking - watcher.go: fsnotify-based file watcher with debouncing - Dockerfile: multi-stage build for Alpine runtime
Diffstat (limited to 'watcher.go')
-rw-r--r--watcher.go210
1 files changed, 210 insertions, 0 deletions
diff --git a/watcher.go b/watcher.go
new file mode 100644
index 0000000..7b823da
--- /dev/null
+++ b/watcher.go
@@ -0,0 +1,210 @@
+package main
+
+import (
+ "log"
+ "os"
+ "path/filepath"
+ "strings"
+ "sync"
+ "time"
+
+ "github.com/fsnotify/fsnotify"
+ ignore "github.com/sabhiram/go-gitignore"
+)
+
+func watch(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 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
+ })
+}