Wire the webhook dispatcher into configuration resolution and the watch
command so scan results are relayed to external services on every frame.
Config (internal/config):
- Config gains Webhooks []webhook.Config (yaml: "webhooks", mapstructure
tags for viper compatibility; duration fields use mapstructure:"-" and
are backfilled via fixWebhookDurations calling v.GetDuration)
- validateWebhook enforces: name non-empty, type in (slack|discord|
generic), URL starts with http/https, rate_limit >= 1s, retry
max_attempts <= 5, min_priority 0-2
- Dump includes the webhooks section
Watch command (cmd/gitflow):
- At startup, builds a webhook.Dispatcher from cfg.Webhooks
- After each scan frame, fires dispatchWebhooks in a goroutine so a slow
webhook never blocks the scan interval
- dispatchWebhooks constructs a webhook.Payload from the scan result,
computes changed repositories, and fans out via d.Dispatch; errors
are printed to stderr
Config tests:
- Webhooks parse from YAML (type, URL, on_change_only, rate_limit,
retry.backoff) with duration fixup verified
- Bad webhooks rejected: unknown type, empty URL, non-http URL,
sub-second rate_limit
Verified: go build, go vet, go test -race (13 packages), gofmt clean.
Implement the webhook dispatch subsystem so watch sessions can relay scan
results to external services when repositories change.
Webhook package (internal/webhook):
- Sender interface (Name + Send(ctx, Payload)) with three concrete
implementations:
- SlackSender: Block Kit message (header + mrkdwn section with summary
and change list + context footer), emoji status markers
- DiscordSender: single rich embed with color-coded sidebar (green=
clean, yellow=attention, red=errors) and markdown description
- GenericSender: raw JSON POST of the Payload struct, with custom
headers from config
- Config struct per destination: type, URL, send_all, on_change_only,
min_priority, rate_limit, timeout, custom headers, retry (max_attempts
+ backoff); TimeoutOrDefault helper
- NewSender factory switches on type; unknown types rejected
- Dispatcher: NewDispatcher builds a senderHandle per config (sender +
rate-limit state + its own http.Client with per-config timeout);
Dispatch() fans out with per-sender guards: on_change_only skips when
changedCount==0, rate_limit skips when too soon (lastSent check)
- senderHandle.sendWithRetry: retries with exponential backoff on
transient errors (5xx/timeout); 4xx errors fail immediately;
isRetryable helper
HTTP client (internal/httpclient):
- PostJSON now skips json.Unmarshal when out==nil, matching the common
webhook pattern where the response body is irrelevant
Testing:
- All three sender types round-trip through httptest (request payload
decoded and asserted)
- Discord embed colour: yellow for attention, red for errors
- Generic sender payload integrity check
- Dispatcher: OnChangeOnly guard skips when changedCount==0; multiple
senders all fire; HTTP errors surfaced (500)
- Retry: 504 Gateway Timeout retried 3× before succeeding via the
dispatcher's sendWithRetry
- NewSender rejects unknown types, requires a name for identification
Verified: go build, go vet, go test -race (13 packages), gofmt clean.
Add the on-demand AI suggestion panel to the TUI so pressing 'a' asks the
configured provider about the current scan result.
Model additions:
- aiSugs field ([]ai.Suggestion) and aiCmd() method that calls
ai.Provider.Suggest(context.Background(), result)
- aiResultMsg/aiErrorMsg message types for the async flow
Update handler:
- 'a' toggles the panel: if visible, it hides; if hidden, it starts the AI
request (aiLoading flag guards against duplicates)
- aiResultMsg populates aiSugs and opens the panel
- aiErrorMsg prepends "AI:" to the error and places it in the status bar
error field (non-blocking — the scan result stays visible)
View:
- aiPanelView renders a separator line, "AI SUGGESTIONS" header, and one
row per suggestion: [priority] action message, with an indented "$ cmd"
line when a command is present
- loading state shows "⏳ asking AI..." with the dim style
- status bar right-hand hints include "a AI" only when a provider is
configured (nil check)
Tests:
- 'a' fires aiCmd; second 'a' toggles the panel off
- aiResultMsg opens the panel and populates aiSugs
- aiErrorMsg closes the panel and sets the error with "AI:" prefix
- aiPanelView contains "AI SUGGESTIONS", priority labels, and messages
- AI toggle hides the panel on second press
Verified: go build, go vet, go test -race (12 packages), gofmt clean.
Replace the static-scan boot in the TUI with a reactive scan loop anchored
on bubbletea.Tick and user-driven 'r' rescan.
Scan loop (tea.Tick instead of goroutine — no leak):
- Init returns a tick Cmd when --interval > 0; on each tick the model
fires scanCmd and chains the next tick in the Update handler, producing
a self-regulating interval loop that stops naturally on quit
- 'r' key triggers an immediate rescan when the model is not already
loading, for on-demand refresh
- scanResultMsg handler records prevResult, computes status.Changed into a
changed set (keyed by repo path), and updates lastScan
- scanErrorMsg stores the error text on the model (displayed in the status
bar) without stopping the loop
Change detection in the view:
- Repos whose state changed since the previous scan get a ▲ prefix (yellow
bold) in the repo list, distinguishing them from the > cursor
Status bar improvements:
- Countdown "next: 23s" when --interval is active, computed from
time.Since(lastScan); "scanning…" shown during async scans
- Updated keybinding hints: r refresh, g/G home/end
Navigation additions:
- g/Home jumps to top, G/End jumps to bottom
Styles:
- Changed style (yellow bold foreground) for ▲ markers
- Styles struct now includes all five variants
Tests:
- Home/End keys navigate to first/last repo
- 'r' fires scanCmd when not loading
- ▲ marker appears for changed repos
- Interval tick fires scan when idle
- Countdown "next:" shown in status bar
Verified: go build, go vet, go test -race (12 packages), gofmt clean.
Ship a working `gitflow tui` command: static scan result rendered as a
lipgloss-styled table, with keyboard navigation and a detail pane.
TUI package (internal/tui):
- Model struct: holds scan result, cursor, detail/help toggles, loading
state, error display, and injected app/AI/config dependencies
- Update: handles WindowSizeMsg (resize), KeyMsg for navigation (j/↑/k/↓,
Home/End, Enter/Space for detail, ? for help, q/Ctrl-C for quit), and
async scanResultMsg/scanErrorMsg for the M3 periodic scan loop
- View: repo list table (STATUS/REPOSITORY/BRANCH/AHEAD-BEHIND/CHGS/STASH)
with cursor highlighting and dimmed error rows, expandable detail pane
showing files, and a status bar (scan summary + keybinding hints)
- styles.go: lipgloss Styles (Header/Row/CursorRow/StatusBar) with
dark/light theme palettes that mirror the CLI's ThemeMode
- Loading and empty states; help overlay with keybinding reference
Presenter API surface (needed by the TUI):
- ShortPath exported (home → ~ collapsing, reused by TUI and CLI)
- StatusSymbolFor exported (✓ ✗ ↑ ↓ ⇄ ◉ ▢ ! symbols, reused)
- ShortPath is kept as a wrapper so internal formatter callers are
unaffected
CLI (cmd/gitflow):
- newTUICmd: resolves config, builds AI provider, calls tui.New() for the
initial scan, and runs the bubbletea Program
- Wired into root command under `gitflow tui` with full flag set
(--dir, --interval, --exclude, etc.)
Dependencies: bubbletea v1.3.10, lipgloss v1.1.0, a stable x/term tree
Testing:
- Model logic tests: cursor navigation (arrow + j/k), bottom/top clamping,
detail toggle (Enter/Space), ? help, q/Ctrl-C quit, view renders
repo names/branch/status columns, detail pane shows file lists, help
overlay shows keybindings, loading/empty states
Verified: go build, go vet, go test -race (12 packages), gofmt clean,
`gitflow tui --help` prints command usage.
Pull the private `client`/`postJSON` from `internal/ai` into a standalone
`internal/httpclient` package so both the AI providers and the upcoming
webhook senders can share the same bounded-reader, timeout-guarded JSON
HTTP client without introducing a dependency cycle.
Changes:
- internal/httpclient: Client struct with PostJSON(ctx, url, headers,
payload, out), functional options WithTimeout/WithTransport, a 4 MiB
response cap, and a 60s default timeout
- internal/ai: three providers (OpenAI, Ollama, Anthropic) now embed an
`*httpclient.Client` (field renamed from `client` to `http`); the old
`client.go` is deleted
- All 11 test packages pass (ai tests are byte-for-byte unaffected)
This zero-behaviour refactor unblocks the webhook package distributed in
M5, which needs the exact same JSON-post-and-decode helper.
Close out the plan with the remaining polish items and a full README.
Shell completions (cmd/gitflow):
- New `completion [bash|zsh|fish|powershell]` command backed by cobra's
generators, wired into the root command
Desktop notifications (internal/notify):
- Send() dispatches to notify-send (Linux) with an osascript fallback
(macOS); Windows is a documented no-op for now; missing notifiers are
silent, never errors
- watch --notify sends a notification listing repositories whose state
changed since the previous frame
Color themes (internal/presenter):
- ThemeMode (dark/light) with ParseTheme; light uses bright ANSI variants
(90-97) that stay legible on light backgrounds; threaded through the
table, compact, and suggestions renderers; new --theme flag validated
and dumped by config
Custom rules (internal/rules):
- Rule{name, field (ahead|behind|stash|changes), op (==,!=,<,<=,>,>=),
value, label} with upfront validation in both Validate and Eval
- Config gains a `rules:` section (yaml/env only, no flag), validated at
load; matches render as a "FLAGS (custom rules)" section via a new
presenter.Flags renderer, shown in scan and watch frames
Docs:
- readme.md fully rewritten: features, install, usage, examples, flag
table, status classes, configuration reference, AI agent behavior and
--ai-execute guardrails, development layout, CI, and future work
- implementation.md gains an Implementation Progress section recording
every phase branch and the deviations from the original plan
Multi-platform:
- Verified cross-compilation for windows/amd64 and darwin/arm64; the
notify package is split behind build tags
Testing:
- rules: validation, operator semantics, Eval ordering, invalid-rule
errors
- presenter: ParseTheme, light-theme bright codes (and absence of
dark-theme codes), Flags rendering (empty = silent, matches render)
- config: rules loading from file, invalid-rule rejection, bad theme and
bad provider rejection
- notify: no-op behaviour when no notifier is installed (skipped when one
is, to avoid firing real notifications)
Verified: go build, go vet, go test -race (10 packages), gofmt clean,
windows/darwin cross-compile, completion generation, rules + light theme
smoke test, watch --notify graceful shutdown (exit 0, no orphans).
Add the AI layer that turns scan results into suggested next actions.
Provider model (internal/ai):
- Provider interface with Name() and Suggest(ctx, ScanResult) returning
[]Suggestion (repo_path, action, message, command, priority 0-2)
- NewProvider factory resolves the configured provider and enforces that
cloud providers have their API key in the configured env var; Ollama
needs no key
- OpenAIProvider: Chat Completions with response_format json_object
- OllamaProvider: local /api/chat with format:json for structured output
- AnthropicProvider: Messages API with system prompt and key/version
headers; all three fall back to provider-appropriate defaults for
base_url and model
- Shared client: 60s timeout, 4 MiB response cap, JSON encode/decode,
HTTP error surfaces the upstream status and body
Prompt design (BuildPrompt):
- Renders the full repository status table (name/status/branch/ahead/
behind/changes) plus strict output rules: exact suggestion schema,
allowed actions, smallest-safe-step guidance, no invented repositories,
return [] when healthy
Parsing (parseSuggestions):
- Tolerates ```json fences and surrounding prose, clamps priorities to
0-2, caps the result, and rejects replies without a JSON array
Suggestion display (presenter.Suggestions):
- "AI SUGGESTIONS" table (repository/action/priority/message/command)
rendered below the scan table with priority color-coded (red/yellow/
green); empty results say all repositories are healthy
Guarded execution (--ai-execute, experimental):
- RunConfirmed executes a suggestion's command inside its repository only
after explicit per-command y/N confirmation, and only for actions on an
allowlist (commit/push/pull/stash/checkout) so LLM output can never run
arbitrary shell commands; cancellation aborts remaining suggestions
CLI wiring:
- scan: AI block after the table when --ai is set and format is not json
(JSON streams stay machine-readable); failures degrade to warnings
- watch: AI is queried only on the first frame and when something changed
since the previous frame, to avoid hammering the provider every interval
- New --ai-execute flag and provider validation in config (openai/ollama/
anthropic), included in the config dump
Testing:
- httptest-based provider tests verifying request shape (model, auth
headers, path), response parsing, API error bodies, HTTP failures, and
cancellation
- Parse tests: plain/fenced/prose replies, empty arrays, garbage,
truncated JSON, priority clamping
- Execution tests: unsafe actions and empty commands never run, declined
confirmations are skipped, confirmed commands execute in the repo dir,
failing commands surface errors
- Presenter suggestion table and empty-state tests
Verified: go build, go vet, go test -race, gofmt clean; end-to-end smoke
test against a local fake Ollama server (request shape confirmed, table +
suggestions rendered) and the missing-API-key warning path.
Add the scheduling layer and the watch command so gitflow can rescan
repositories on an interval and surface state changes.
Scheduler (internal/scheduler):
- Scheduler runs a callback immediately and then on a time.Ticker until
the context is cancelled; interval <= 0 means a single run
- Graceful shutdown: cancellation is never reported as an error (checked
on the first run and after every run), so Ctrl-C / SIGTERM exit cleanly
- A non-cancellation run error stops the loop and is propagated
Change detection (pkg/status):
- Changed(prev, next) compares snapshots per repository path — status,
branch, detached flag, ahead/behind, stash count, and file counts —
and returns the repositories whose observable state differs, including
repositories that newly appear
Watch command (cmd/gitflow):
- newWatchCmd validates that --interval is positive, prompts for the
parent directory on a TTY when --dir is absent, and drives the
scheduler with ScanOnce
- Each frame clears the screen on a terminal (or prints a RFC3339 header
when output is piped, so watch doubles as a lightweight logger) and
renders through the presenter
- Footer shows changed repositories since the previous frame ("▲ name:
status") and the next scan time; JSON format emits one document per
frame for scripting
- SIGINT/SIGTERM handled via signalContext for a clean stop
Testing:
- Scheduler: single run, periodic repetition, stop-on-error, pre-cancelled
context, and cancellation-during-run (no error reported)
- status.Changed: unchanged repos ignored; status, file-count, and
newly-appeared repos detected
Verified: go build, go vet, go test -race, gofmt clean; manual watch
smoke test over a scratch directory with 1s interval — frames render,
SIGINT and SIGTERM both exit 0 with no orphaned processes.
Replace the interim renderers with a dedicated presenter package that
formats scan results in three ways.
Presenter (internal/presenter):
- Formatter interface with Present()/For() dispatch on format name:
table, json, compact
- TableFormatter: aligned tabwriter table with REPOSITORY / BRANCH /
STATUS / AHEAD-BEHIND / CHANGES / STASH columns, per-repo change
summaries like "2M 1U", error details inline, and a colored summary
line ("N repos | N clean | N need attention | N errors")
- JSONFormatter: indented document with scanned_at, parent_dir, repos,
and an aggregate summary for scripting; statuses render as string
labels ("modified") instead of raw integers
- CompactFormatter: one line per repo with color-coded status symbols
(✓ ✗ ↑ ↓ ⇄ ◉ ▢ !) plus branch, ahead/behind, and change counts
- Color handling: auto/always/never modes, TTY detection, and the
NO_COLOR convention (explicit --color=always still wins); paths have
$HOME collapsed to "~" in table and compact views
Domain model (pkg/status):
- JSON tags on RepoInfo/ScanResult/Summary for clean field names
- RepoStatus now marshals to its string label and unmarshals from both
string labels and numeric values, so JSON output round-trips
Configuration (internal/config):
- New --color flag (auto/always/never) validated in Load and included in
the config dump; keyed as "color" in viper
CLI (cmd/gitflow):
- scan now routes through presenter.Present with the resolved color mode;
the interim renderers are removed
Testing:
- Table content (columns, change summaries, error text, summary line) and
absence of escape codes with ColorNever
- ColorAlways emits ANSI codes even under NO_COLOR; auto stays clean on
non-terminal writers
- JSON decodes back into the domain types (string statuses round-trip)
- Compact symbols and counts; unknown formats rejected
- RepoStatus JSON round trip covers every status
Verified: go build, go vet, go test -race, gofmt clean; manual smoke of
table / compact / forced-color / JSON output against a scratch directory.
Add the Cobra-based command surface and the flag/env/config-file
resolution layer that all commands share.
Configuration (internal/config):
- Load() resolves settings with the documented precedence flags > env >
config file > defaults, via viper: GITFLOW_-prefixed env vars with
dot-to-underscore mapping, plus ~/.gitflow.yaml (or $GITFLOW_CONFIG)
- RegisterFlags/NewFlagSet own the flag definitions so every command and
the tests share a single source of truth
- Config/Validate/Dump cover dir, interval, format (table/json/compact),
exclude globs, max depth, worker count, and the AI block (enabled,
provider, model, api_key_env, base_url); ConfigFile records the loaded
path; Dump renders the effective config as human-readable YAML with the
interval as a duration string
App orchestration (internal/app):
- New() validates the configuration at the boundary (fail fast)
- ScanOnce() runs discovery then a concurrent status scan, warning on
stderr and continuing when discovery is only partially successful
(e.g. permission-denied subtrees), and bundles everything into a
ScanResult
CLI (cmd/gitflow):
- root command with scan / config / version subcommands
- scan: resolves config, prompts for the parent directory when stdin is a
TTY and --dir was not given (per the README), runs a single pass, and
renders the result — interim plain/JSON output until phase 3 lands the
presenter package
- config: prints the effective configuration
- version: prints the build version (ldflags-injectable)
- signalContext() wires SIGINT/SIGTERM into a cancellable context for
graceful shutdown
Testing:
- config: defaults, flag overrides, env overrides, flag-beats-env
precedence, config file loading (including duration and slice values),
GITFLOW_CONFIG path override, validation failures, and Dump output
- app: config validation on New, end-to-end ScanOnce over a real temp
repo, and missing-directory errors
Verified: go build, go vet, go test -race, gofmt clean; manual smoke of
`gitflow version`, `gitflow config`, and `gitflow scan -d <dir>` against a
scratch directory with a dirty repo.
Implement the core data layer that everything else builds on: walking a
parent directory for Git repositories and snapshotting each repository's
state via porcelain commands.
Domain model (pkg/status):
- RepoStatus enum with zero value = StatusUnknown (invalid/unset), covering
clean / modified / ahead / behind / diverged / detached / bare / error
- RepoInfo snapshot: path, name, remote URL, branch, detached flag, staged /
modified / untracked file lists, ahead/behind counts, stash count, error
- ScanResult with computed Summary counters and NeedsAttention filter
Git wrapper (internal/git):
- Executor built with functional options (binary, timeout, env), defaults to
"git" with a 10s per-command timeout so a hung repository can never stall
a scan; context cancellation honoured via exec.CommandContext
- Status() validates with rev-parse --is-bare-repository (bare repos are
reported as StatusBare and skipped), then parses `git status
--porcelain=v2 --branch -z`
- Parsing uses the NUL-separated v2 format so paths with spaces, quotes,
and tabs survive verbatim (no C-quoting to decode); rename records (type
2) and ahead/behind lines (# branch.ab) are handled, unknown tokens are
ignored for forward compatibility
- Best-effort remote URL (git config --get-regexp) and stash count
Discovery (internal/scanner/discover.go):
- WalkDir-based traversal that never descends into .git internals
- Detects working trees via .git directory or .git pointer file (linked
worktrees, submodule checkouts) and bare repos via a *.git directory
carrying its own HEAD/objects/refs
- WithExclude glob patterns (matched against full path and base name) and
WithMaxDepth depth limiting as functional options
- Walk errors (e.g. permission denied) are aggregated and returned
alongside partial results via errors.Join; cancellation is propagated
Scanner (internal/scanner/scanner.go):
- Concurrent status scanning with a bounded worker pool (errgroup +
SetLimit, default 8 workers)
- Per-repo failures become StatusError entries instead of aborting the
pass; a cancelled context aborts the whole scan
Testing:
- Table-driven parser tests with canned NUL-separated porcelain output
- Integration tests against a real git binary (clean / modified / staged /
stashed / detached / bare / non-repo)
- Discovery tests over a fixture tree with nested repos, a bare repo, a
linked worktree, and an exclusion target
- Scanner tests for mixed success/failure, bounded concurrency, and
cancellation; everything runs under -race
Note: LastFetch from the original plan was dropped — the only reliable
source is a reflog of the remote-tracking ref, which does not exist on
fresh clones, so it would always be misleading. The model remains
extensible if a fetch-history feature is wanted later.
Bootstrap the gitflow module so subsequent phases build on a verified,
linted foundation.
- go.mod: initialize module gitea.oblak.solutions/dimitar/gitFlow at go 1.24
- cmd/gitflow/main.go: minimal entrypoint that prints the version string;
this is a placeholder that Phase 2 (CLI & config) replaces with Cobra
- internal/version: version metadata injectable at build time via ldflags,
with a Makefile target wiring VERSION through
- Makefile: build / test / fmt / vet / lint / install / clean targets with
ldflags version injection
- .golangci.yml: enable errcheck, govet, staticcheck, gosimple, ineffassign,
unused, misspell, gofmt, goimports
- .github/workflows/ci.yml: GitHub Actions CI running build + vet + tests
(with -race) across Go 1.24/1.26, plus a golangci-lint job
- .gitignore: build artifacts, test outputs, editor cruft, and local config
overrides (never committed)
Verified locally: go build, go vet, gofmt clean, binary prints version.