package dev import ( "context" "fmt" "io" "os" "os/exec" "path/filepath" "strings" "syscall" "time" "github.com/fsnotify/fsnotify" "git.golem15.com/golem15/summercms/internal/build" ) const defaultDebounce = 200 * time.Millisecond // Options customizes the watch loop. Tests inject Build and Start. type Options struct { Build func(ctx context.Context, appDir string, out io.Writer) error Start func(ctx context.Context, binPath string, stdout, stderr io.Writer) (*exec.Cmd, error) Debounce time.Duration } // Watch rebuilds the app on source changes and restarts the child binary. func Watch(ctx context.Context, appDir string, out io.Writer) error { return watch(ctx, appDir, out, Options{}) } func watch(ctx context.Context, appDir string, out io.Writer, opts Options) error { if appDir == "" { return fmt.Errorf("dev: app directory is empty") } abs, err := filepath.Abs(appDir) if err != nil { return fmt.Errorf("dev: resolve app directory: %w", err) } appDir = abs if out == nil { out = os.Stdout } if opts.Build == nil { opts.Build = build.App } if opts.Start == nil { opts.Start = startBin } if opts.Debounce <= 0 { opts.Debounce = defaultDebounce } m, err := build.LoadManifest(filepath.Join(appDir, "summer.yaml")) if err != nil { return err } binPath := filepath.Join(appDir, "bin", m.Binary) watcher, err := fsnotify.NewWatcher() if err != nil { return fmt.Errorf("dev: watcher: %w", err) } defer watcher.Close() if err := addWatches(watcher, appDir); err != nil { return err } var current *child defer func() { current.stop() }() rebuild := func() { if ctx.Err() != nil { return } start := time.Now() if err := opts.Build(ctx, appDir, out); err != nil { fmt.Fprintf(out, "build error: %v\n", err) return } fmt.Fprintf(out, "rebuild: %s\n", time.Since(start).Round(time.Millisecond)) if ctx.Err() != nil { return } next, err := startChild(ctx, opts, binPath, out) if err != nil { fmt.Fprintf(out, "start error: %v\n", err) return } current.stop() current = next } rebuild() events := make(chan struct{}, 1) watchErrs := make(chan error, 1) go func() { for { select { case <-ctx.Done(): return case ev, ok := <-watcher.Events: if !ok { return } if ev.Has(fsnotify.Create) { if info, err := os.Stat(ev.Name); err == nil && info.IsDir() && !ignoredPath(appDir, ev.Name) { _ = watcher.Add(ev.Name) } } if !relevant(appDir, ev) { continue } select { case events <- struct{}{}: default: } case err, ok := <-watcher.Errors: if !ok { return } if err == nil { continue } select { case watchErrs <- err: default: } } } }() var timer *time.Timer timerC := func() <-chan time.Time { if timer == nil { return nil } return timer.C } for { select { case <-ctx.Done(): current.stop() current = nil return ctx.Err() case err := <-watchErrs: fmt.Fprintf(out, "watch error: %v\n", err) case <-events: if timer == nil { timer = time.NewTimer(opts.Debounce) } else { if !timer.Stop() { select { case <-timer.C: default: } } timer.Reset(opts.Debounce) } case <-timerC(): timer = nil rebuild() } } } type child struct { cmd *exec.Cmd wait <-chan error } func startChild(ctx context.Context, opts Options, binPath string, out io.Writer) (*child, error) { cmd, err := opts.Start(ctx, binPath, out, out) if err != nil { return nil, err } if cmd == nil || cmd.Process == nil { return nil, fmt.Errorf("dev: start returned no process") } ch := make(chan error, 1) go func() { ch <- cmd.Wait() }() return &child{cmd: cmd, wait: ch}, nil } func (c *child) stop() { if c == nil || c.cmd == nil || c.cmd.Process == nil { return } _ = c.cmd.Process.Signal(syscall.SIGTERM) select { case <-c.wait: case <-time.After(2 * time.Second): _ = c.cmd.Process.Kill() <-c.wait } } func startBin(_ context.Context, binPath string, stdout, stderr io.Writer) (*exec.Cmd, error) { cmd := exec.Command(binPath) cmd.Stdout = stdout cmd.Stderr = stderr cmd.Stdin = os.Stdin if err := cmd.Start(); err != nil { return nil, err } return cmd, nil } func addWatches(watcher *fsnotify.Watcher, root string) error { return filepath.WalkDir(root, func(path string, d os.DirEntry, err error) error { if err != nil { return err } if !d.IsDir() { return nil } if ignoredPath(root, path) && path != root { return filepath.SkipDir } return watcher.Add(path) }) } func relevant(appDir string, ev fsnotify.Event) bool { if ev.Op == fsnotify.Chmod { return false } if ignoredPath(appDir, ev.Name) { return false } return watchedName(filepath.Base(ev.Name)) } func watchedName(base string) bool { switch base { case "go.mod", "go.work", ".env", "summer.yaml": return true } switch filepath.Ext(base) { case ".go", ".yaml", ".yml": return true } return false } func ignoredPath(root, path string) bool { rel, err := filepath.Rel(root, path) if err != nil { return true } if rel == "main.go" || rel == "plugins.gen.go" { return true } for _, part := range strings.Split(rel, string(os.PathSeparator)) { if part == ".git" || part == "bin" || part == "tmp" { return true } } return false }