Poll source files/directories and regenerate output on change. Output path is excluded from watching; errors don't stop the loop. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
153 lines
3.7 KiB
Go
153 lines
3.7 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"io"
|
|
"io/fs"
|
|
"os"
|
|
"os/signal"
|
|
"path/filepath"
|
|
"strings"
|
|
"syscall"
|
|
"time"
|
|
)
|
|
|
|
// watchSnapshot maps a file path to its modification time and size.
|
|
type watchSnapshot map[string]string
|
|
|
|
// takeWatchSnapshot records the state of every file under the given paths.
|
|
// Directories are walked recursively. Anything at or below the excluded path
|
|
// (typically the output path) is skipped so regenerating output does not
|
|
// retrigger the watcher. Missing paths are simply absent from the snapshot, so
|
|
// creating them later counts as a change.
|
|
func takeWatchSnapshot(paths []string, exclude string) watchSnapshot {
|
|
snap := watchSnapshot{}
|
|
exclude = absPathOrSelf(exclude)
|
|
for _, root := range paths {
|
|
_ = filepath.WalkDir(root, func(p string, d fs.DirEntry, err error) error {
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
if exclude != "" && isWithin(absPathOrSelf(p), exclude) {
|
|
if d.IsDir() {
|
|
return filepath.SkipDir
|
|
}
|
|
return nil
|
|
}
|
|
if d.IsDir() {
|
|
return nil
|
|
}
|
|
info, err := d.Info()
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
snap[p] = fmt.Sprintf("%d-%d", info.ModTime().UnixNano(), info.Size())
|
|
return nil
|
|
})
|
|
}
|
|
return snap
|
|
}
|
|
|
|
func (s watchSnapshot) equal(o watchSnapshot) bool {
|
|
if len(s) != len(o) {
|
|
return false
|
|
}
|
|
for k, v := range s {
|
|
if o[k] != v {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
func absPathOrSelf(p string) string {
|
|
if p == "" {
|
|
return ""
|
|
}
|
|
if abs, err := filepath.Abs(p); err == nil {
|
|
return abs
|
|
}
|
|
return p
|
|
}
|
|
|
|
// isWithin reports whether path equals dir or is located below it.
|
|
func isWithin(path, dir string) bool {
|
|
if path == dir {
|
|
return true
|
|
}
|
|
return strings.HasPrefix(path, dir+string(filepath.Separator))
|
|
}
|
|
|
|
// watchLoop runs fn once immediately and again whenever the watched paths
|
|
// change, until ctx is cancelled. Changes are debounced: fn runs only after
|
|
// the snapshot has stayed unchanged for one poll interval. Errors from fn are
|
|
// reported to w and do not stop the loop.
|
|
func watchLoop(ctx context.Context, w io.Writer, paths []string, exclude string, interval time.Duration, fn func() error) {
|
|
run := func() {
|
|
if err := fn(); err != nil {
|
|
fmt.Fprintf(w, "Error: %v\n", err)
|
|
}
|
|
fmt.Fprintf(w, "Watching for changes (Ctrl-C to stop)...\n")
|
|
}
|
|
|
|
last := takeWatchSnapshot(paths, exclude)
|
|
run()
|
|
|
|
ticker := time.NewTicker(interval)
|
|
defer ticker.Stop()
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
return
|
|
case <-ticker.C:
|
|
}
|
|
cur := takeWatchSnapshot(paths, exclude)
|
|
if cur.equal(last) {
|
|
continue
|
|
}
|
|
// Debounce: wait until writes settle.
|
|
for settled := false; !settled; {
|
|
select {
|
|
case <-ctx.Done():
|
|
return
|
|
case <-time.After(interval):
|
|
}
|
|
next := takeWatchSnapshot(paths, exclude)
|
|
settled = next.equal(cur)
|
|
cur = next
|
|
}
|
|
fmt.Fprintf(w, "\nChange detected, regenerating...\n")
|
|
last = cur
|
|
run()
|
|
}
|
|
}
|
|
|
|
// runConvertWatch runs the conversion once and then again whenever the source
|
|
// files change, until interrupted.
|
|
func runConvertWatch(parent context.Context, w io.Writer, run func() error) error {
|
|
var paths []string
|
|
switch {
|
|
case len(convertFromList) > 0:
|
|
paths = convertFromList
|
|
case convertSourcePath != "":
|
|
paths = []string{convertSourcePath}
|
|
default:
|
|
return fmt.Errorf("--watch requires --from-path or --from-list (live database connections cannot be watched)")
|
|
}
|
|
if convertDryRun {
|
|
return fmt.Errorf("--watch cannot be combined with --dry-run")
|
|
}
|
|
if convertWatchInterval <= 0 {
|
|
return fmt.Errorf("--watch-interval must be positive")
|
|
}
|
|
if parent == nil {
|
|
parent = context.Background()
|
|
}
|
|
|
|
ctx, stop := signal.NotifyContext(parent, os.Interrupt, syscall.SIGTERM)
|
|
defer stop()
|
|
watchLoop(ctx, w, paths, convertTargetPath, convertWatchInterval, run)
|
|
return nil
|
|
}
|