generalise file watcher to an UpdateListener
4 files changed, 185 insertions(+), 153 deletions(-)
A internal/update/file.go
@@ -0,0 +1,137 @@ +package update + +import ( + "io/fs" + "os" + "path" + "path/filepath" + "slices" + "time" + + "go.alanpearce.eu/x/log" + + "github.com/fsnotify/fsnotify" + "gitlab.com/tozd/go/errors" +) + +var ( + ignores = []string{ + "*.templ", + "*.go", + } + checkSettleInterval = 200 * time.Millisecond +) + +type FileWatcher struct { + log *log.Logger + *fsnotify.Watcher +} + +func NewFileWatcher(logger *log.Logger, dirs ...string) (*FileWatcher, error) { + fsn, err := fsnotify.NewWatcher() + if err != nil { + return nil, errors.WithMessage(err, "could not create file watcher") + } + + for _, dir := range dirs { + err = fsn.Add(dir) + if err != nil { + return nil, errors.WithMessagef(err, "could not add directory %s to file watcher", dir) + } + } + + return &FileWatcher{ + Watcher: fsn, + log: logger, + }, nil +} + +func (fw *FileWatcher) matches(name string) func(string) bool { + return func(pattern string) bool { + matched, err := path.Match(pattern, name) + if err != nil { + fw.log.Warn("error checking watcher ignores", "error", err) + } + + return matched + } +} + +func (fw *FileWatcher) ignored(pathname string) bool { + return slices.ContainsFunc(ignores, fw.matches(path.Base(pathname))) +} + +func (fw *FileWatcher) AddRecursive(from string) error { + fw.log.Debug("walking directory tree", "root", from) + err := filepath.WalkDir(from, func(path string, entry fs.DirEntry, err error) error { + if err != nil { + return errors.WithMessagef(err, "could not walk directory %s", path) + } + if entry.IsDir() { + if entry.Name() == ".git" { + fw.log.Debug("skipping directory", "entry", entry.Name()) + + return fs.SkipDir + } + fw.log.Debug("adding directory to watcher", "path", path) + if err = fw.Add(path); err != nil { + return errors.WithMessagef(err, "could not add directory %s to watcher", path) + } + } + + return nil + }) + + return errors.WithMessage(err, "error walking directory tree") +} + +func (fw *FileWatcher) Wait(events chan<- Event, errs chan<- error) error { + var timer *time.Timer + + go func() { + var fileEvents []fsnotify.Event + for { + select { + case baseEvent := <-fw.Watcher.Events: + if !fw.ignored(baseEvent.Name) { + fw.log.Debug( + "watcher event", + "name", + baseEvent.Name, + "op", + baseEvent.Op.String(), + ) + if baseEvent.Has(fsnotify.Create) || baseEvent.Has(fsnotify.Rename) { + f, err := os.Stat(baseEvent.Name) + if err != nil { + errs <- errors.WithMessagef(err, "error handling event %s", baseEvent.Op.String()) + } else if f.IsDir() { + err = fw.Add(baseEvent.Name) + if err != nil { + errs <- errors.WithMessage(err, "error adding new folder to watcher") + } + } + } + if baseEvent.Has(fsnotify.Rename) || baseEvent.Has(fsnotify.Write) || + baseEvent.Has(fsnotify.Create) || baseEvent.Has(fsnotify.Chmod) { + fileEvents = append(fileEvents, baseEvent) + if timer == nil { + timer = time.AfterFunc(checkSettleInterval, func() { + events <- Event{ + FileEvents: fileEvents, + Revision: "", + } + fileEvents = []fsnotify.Event{} + }) + } + timer.Reset(checkSettleInterval) + } + } + case err := <-fw.Watcher.Errors: + errs <- errors.WithMessage(err, "error in watcher") + } + } + }() + + return nil +}
A internal/update/update.go
@@ -0,0 +1,12 @@ +package update + +import "github.com/fsnotify/fsnotify" + +type Listener interface { + Wait(chan<- Event, chan<- error) error +} + +type Event struct { + FileEvents []fsnotify.Event + Revision string +}
D internal/watcher/watcher.go
@@ -1,114 +0,0 @@ -package watcher - -import ( - "fmt" - "io/fs" - "os" - "path" - "path/filepath" - "slices" - "time" - - "go.alanpearce.eu/x/log" - - "github.com/fsnotify/fsnotify" - "gitlab.com/tozd/go/errors" -) - -type FileWatcher struct { - *fsnotify.Watcher -} - -var ( - l *log.Logger - ignores = []string{ - "*.templ", - "*.go", - } - checkSettleInterval = 200 * time.Millisecond -) - -func matches(name string) func(string) bool { - return func(pattern string) bool { - matched, err := path.Match(pattern, name) - if err != nil { - l.Warn("error checking watcher ignores", "error", err) - } - - return matched - } -} - -func ignored(pathname string) bool { - return slices.ContainsFunc(ignores, matches(path.Base(pathname))) -} - -func New(log *log.Logger) (*FileWatcher, error) { - watcher, err := fsnotify.NewWatcher() - if err != nil { - return nil, errors.WithMessage(err, "could not create watcher") - } - l = log - - return &FileWatcher{watcher}, nil -} - -func (watcher FileWatcher) AddRecursive(from string) error { - l.Debug("walking directory tree", "root", from) - err := filepath.WalkDir(from, func(path string, entry fs.DirEntry, err error) error { - if err != nil { - return errors.WithMessagef(err, "could not walk directory %s", path) - } - if entry.IsDir() { - if entry.Name() == ".git" { - l.Debug("skipping directory", "entry", entry.Name()) - - return fs.SkipDir - } - l.Debug("adding directory to watcher", "path", path) - if err = watcher.Add(path); err != nil { - return errors.WithMessagef(err, "could not add directory %s to watcher", path) - } - } - - return nil - }) - - return errors.WithMessage(err, "error walking directory tree") -} - -func (watcher FileWatcher) Start(callback func(string)) { - var timer *time.Timer - for { - select { - case event := <-watcher.Events: - if !ignored(event.Name) { - l.Debug("watcher event", "name", event.Name, "op", event.Op.String()) - if event.Has(fsnotify.Create) || event.Has(fsnotify.Rename) { - f, err := os.Stat(event.Name) - if err != nil { - l.Error( - fmt.Sprintf("error handling %s event: %v", event.Op.String(), err), - ) - } else if f.IsDir() { - err = watcher.Add(event.Name) - if err != nil { - l.Error(fmt.Sprintf("error adding new folder to watcher: %v", err)) - } - } - } - if event.Has(fsnotify.Rename) || event.Has(fsnotify.Write) || - event.Has(fsnotify.Create) || event.Has(fsnotify.Chmod) { - if timer == nil { - timer = time.AfterFunc(checkSettleInterval, func() { - callback(event.Name) - }) - } - timer.Reset(checkSettleInterval) - } - } - case err := <-watcher.Errors: - l.Error("error in watcher", "error", err) - } - } -}
M internal/website/website.go → internal/website/website.go
@@ -13,8 +13,8 @@ ihttp "go.alanpearce.eu/website/internal/http" "go.alanpearce.eu/website/internal/server" "go.alanpearce.eu/website/internal/storage" "go.alanpearce.eu/website/internal/storage/sqlite" + "go.alanpearce.eu/website/internal/update" "go.alanpearce.eu/website/internal/vcs" - "go.alanpearce.eu/website/internal/watcher" "go.alanpearce.eu/website/templates" "go.alanpearce.eu/x/log"@@ -110,51 +110,48 @@ return nil, errors.WithMessage(err, "could not build site") } if opts.Development { - fw, err := watcher.New(log.Named("watcher")) + fw, err := update.NewFileWatcher( + log.Named("watcher"), + opts.Source, + "templates", + ) if err != nil { - return nil, errors.WithMessage(err, "could not create file watcher") - } - err = fw.AddRecursive(opts.Source) - if err != nil { - return nil, errors.WithMessage( - err, - "could not add directory to file watcher", - ) - } - err = fw.AddRecursive("templates") - if err != nil { - return nil, errors.WithMessage( - err, - "could not add templates directory to file watcher", - ) + return nil, errors.WithMessage(err, "could not create file listener") } - go fw.Start(func(filename string) { - log.Info("rebuilding site", "changed_file", filename) - db.Close() - db, err = sqlite.OpenDB(opts.DBPath) + go func(events chan update.Event, errs chan error) { + err := fw.Wait(events, errs) if err != nil { - log.Error("error opening database", "error", err) + log.Panic("could not start update listener", "error", err) } - website.reader, err = sqlite.NewReader(db, log.Named("reader")) - if err != nil { - log.Error("error creating sqlite reader", "error", err) - } - builderOptions.Storage, err = sqlite.NewWriter( - db, - log.Named("storage"), - &sqlite.Options{}, - ) - if err != nil { - log.Error("error creating sqlite writer", "error", err) - } + for event := range events { + filename := event.FileEvents[0].Name + log.Info("rebuilding site", "changed_file", filename) + db.Close() + db, err = sqlite.OpenDB(opts.DBPath) + if err != nil { + log.Error("error opening database", "error", err) + } + website.reader, err = sqlite.NewReader(db, log.Named("reader")) + if err != nil { + log.Error("error creating sqlite reader", "error", err) + } + builderOptions.Storage, err = sqlite.NewWriter( + db, + log.Named("storage"), + &sqlite.Options{}, + ) + if err != nil { + log.Error("error creating sqlite writer", "error", err) + } - err := rebuild(builderOptions, cfg, log) - if err != nil { - log.Error("error rebuilding site", "error", err) + err := rebuild(builderOptions, cfg, log) + if err != nil { + log.Error("error rebuilding site", "error", err) + } + opts.LiveReload.Reload() } - opts.LiveReload.Reload() - }) + }(make(chan update.Event, 1), make(chan error, 1)) } website.reader, err = sqlite.NewReader(db, log.Named("reader"))