package main import ( "context" "errors" "fmt" "os" "strings" "time" "github.com/spf13/cobra" "gitea.oblak.solutions/dimitar/gitFlow/internal/app" "gitea.oblak.solutions/dimitar/gitFlow/internal/config" "gitea.oblak.solutions/dimitar/gitFlow/internal/notify" "gitea.oblak.solutions/dimitar/gitFlow/internal/presenter" "gitea.oblak.solutions/dimitar/gitFlow/internal/scheduler" "gitea.oblak.solutions/dimitar/gitFlow/internal/webhook" "gitea.oblak.solutions/dimitar/gitFlow/pkg/status" ) // newWatchCmd repeatedly scans on an interval until interrupted. func newWatchCmd() *cobra.Command { cmd := &cobra.Command{ Use: "watch", Short: "Repeatedly scan repositories on an interval", Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, _ []string) error { cfg, err := config.Load(cmd.Flags()) if err != nil { return err } if cfg.Interval <= 0 { return errors.New("watch requires a positive --interval (e.g. --interval 30s)") } // Same interactive prompt as scan, per the README. if !cmd.Flags().Changed("dir") && isTerminal(os.Stdin) { if d, err := promptDir(cfg.Dir); err == nil { cfg.Dir = d } } ctx, stop := signalContext() defer stop() a, err := app.New(cfg) if err != nil { return err } opts := presenter.Options{ Color: presenter.ParseColorMode(cfg.Color), Theme: presenter.ParseTheme(cfg.Theme), } whDispatcher := webhook.NewDispatcher(cfg.Webhooks) var prev status.ScanResult first := true run := func(ctx context.Context) error { result, err := a.ScanOnce(ctx) if err != nil { return err } if cfg.Format == "json" { return presenter.Present(os.Stdout, cfg.Format, result, opts) } renderWatchFrame(result, prev, first, cfg, opts) // Ask the AI only on the first frame and when something // changed, so the provider is not hammered every interval. if cfg.AI.Enabled && (first || len(status.Changed(prev, result)) > 0) { renderSuggestions(ctx, cfg, opts, result) } if err := renderRuleFlags(opts, cfg, result); err != nil { return err } if !first && cfg.Notify { notifyChanges(prev, result) } go dispatchWebhooks(ctx, whDispatcher, prev, result) prev = result first = false return nil } sched := scheduler.New(cfg.Interval, run) return sched.Run(ctx) }, } config.RegisterFlags(cmd.Flags()) return cmd } // notifyChanges sends a desktop notification listing repositories whose // state changed since the previous frame. func notifyChanges(prev, result status.ScanResult) { changed := status.Changed(prev, result) if len(changed) == 0 { return } names := make([]string, 0, len(changed)) for _, r := range changed { names = append(names, r.Name) } _ = notify.Send("gitflow: changes detected", strings.Join(names, ", ")) } // dispatchWebhooks builds a Payload from the scan and fans it to the // dispatcher in a separate goroutine so a slow webhook never blocks the // scan interval. func dispatchWebhooks(ctx context.Context, d *webhook.Dispatcher, prev, result status.ScanResult) { if d == nil { return } changed := status.Changed(prev, result) p := webhook.Payload{ Timestamp: time.Now(), ParentDir: result.ParentDir, Summary: result.Summary(), Changed: changed, } if errs := d.Dispatch(ctx, p, len(changed)); len(errs) > 0 { for _, e := range errs { fmt.Fprintf(os.Stderr, "webhook: %v\n", e) } } } // renderWatchFrame clears the screen (or prints a timestamp header when // output is not a terminal), renders the scan, and prints a footer with // changed repositories and the next scan time. func renderWatchFrame(result, prev status.ScanResult, first bool, cfg *config.Config, opts presenter.Options) { if isTerminal(os.Stdout) { fmt.Fprint(os.Stdout, "\x1b[2J\x1b[H") } else { fmt.Fprintf(os.Stdout, "--- %s ---\n", time.Now().Format(time.RFC3339)) } if err := presenter.Present(os.Stdout, cfg.Format, result, opts); err != nil { fmt.Fprintf(os.Stderr, "render: %v\n", err) return } if !first { for _, r := range status.Changed(prev, result) { fmt.Fprintf(os.Stdout, "▲ %s: %s\n", r.Name, r.Status) } } fmt.Fprintf(os.Stdout, "watching %s every %s — next scan at %s (Ctrl-C to stop)\n", cfg.Dir, cfg.Interval, time.Now().Add(cfg.Interval).Format("15:04:05")) }