6.1 KiB
6.1 KiB
Step 06: Watcher and Scheduling
Prerequisites: steps 01, 02, 04.
Read README.md and 00-legacy-analysis.md first.
Goal
Replace the legacy blocking main loop with a non-blocking, observable scheduler.
Design
Create package de.hoennig.gittally.watcher:
Watchercomponent with a fixed-delay poll cycle (Spring@Scheduledor a managed executor; enabled only in server/watch mode, not during CLI commands or tests).- One poll cycle, never blocking on a build:
fetchOrigin()(errors: log, publish watcher health state, retry next cycle — no internal retry-sleep loops like legacyretry_origin_change_check).- Determine candidate branches: changed local branches, plus new origin branches within
watcher.newBranchMaxAge(step 02 operations). - Check auto-build time slots (below).
- Start builds for due branches via
startBuild(branch, commit)(async). The executor prepares a per-branch worktree itself (step 04 amendment) — the watcher must never check out or reset the primary worktree. Multiple branches may build concurrently (builds.maxConcurrent); the executor already serializes builds of the same branch, so the watcher only has to avoid enqueueing a branch that is already pending or running. - Run repository retention pruning and artifact pruning; also remove worktrees under
.git/gittally/worktrees/of branches no longer on origin (git worktree removeor delete +worktreePrune).
- Decide here (or defer with a note): when a new commit arrives for a branch whose build is still running, keep the current queue-behind behavior or cancel the running build and start fresh — this may become a per-branch config option.
- Startup sequence (port of legacy recovery): mark stale running builds interrupted, then enqueue restartable branches.
- Auto-builds: per-branch
autoBuild.enabled+times(UTC HH:MM) from the mergedbranchesconfig. Persist "already triggered for slot/day" state via a small JSON file next to the build results (replacesauto-builds.tsv). - Expose watcher state (last poll time, last fetch error, queue) for the UI/status endpoints.
Out of Scope
- HTTP endpoints (step 07); expose state via a service bean only.
- Retry-failed-builds command (step 10 triggers it through the same queue).
Config
New key watcher.pollInterval (e.g. 10s, default matching legacy cadence).
Update GitTallyConfig, InitCommand templates, and docs/configuration.md together.
Tests
- Poll cycle unit tests with MockK: branch selection precedence, skip-when-building, fetch failure resilience.
- Auto-build slot logic: slot matching, per-day dedup, state persistence.
- New-branch age filtering.
- Startup recovery: interrupted marking and re-enqueue (integration with steps 01/04 fakes).
- No test may sleep for real poll intervals; trigger cycles directly.
Acceptance Criteria
./gradlew ktlintFormatthen./gradlew buildis green.- A test proves a poll cycle completes while a (fake) build is running.
Execution Notes (done 2026-07-07)
Implemented as designed in de.hoennig.gittally.watcher; build green, 23 new tests
(WatcherTest, AutoBuildStateTest, plus new DurationParserTest and GitServiceTest cases).
Deviations and decisions:
- The poll loop is a managed single-thread
ScheduledExecutorServicewith fixed delay, not Spring@Scheduled: scheduling annotations would run during CLI commands and tests. Nothing starts automatically; server/watch mode (step 07) must callWatcher.start(workingDir)explicitly, which runs the startup recovery and then polls immediately and on everywatcher.pollInterval.ApplicationContextTeststays green and fast because constructing the beans schedules nothing. - "Changed" is detected against the result repository (
latestFor(branch).commit != origin head), gated byhasNewCommits, instead of resetting local refs like legacy: builds run detached in per-branch worktrees and never move local branch refs, so a legacy-style local-ref comparison would re-enqueue the same commit forever. Consequence: a failed build of a commit is not retried automatically (same as legacy) — step 10 adds the retry command. - Queue-behind vs. cancel-on-new-commit: DEFERRED, keeping queue-behind semantics at watcher granularity. A branch whose latest build is PENDING or RUNNING is skipped; the new commit is picked up on a later cycle once the build finished. Making this per-branch configurable (cancel and rebuild instead) remains open, see also the step 04 amendment.
- Enqueue precedence per cycle: changed local branches, then recent new origin branches, then due auto-build slots; each branch at most once (an auto-build slot stays untriggered while its branch is pending/running and fires on a later cycle instead of being lost).
- Auto-build state lives in
.git/gittally/auto-builds.json(FileAutoBuildState, replacesauto-builds.tsv); entries of past days are dropped on write. Slot matching (AutoBuildSlots) picks the latest slot at or before the current UTC time, like legacyauto_build_check. Only branches named in thebranchesconfig (other thandefault) can auto-build;default.autoBuild.enableddoes not extend to unlisted branches. - Startup recovery re-enqueues branches whose latest result is PENDING or INTERRUPTED and which still exist on origin, after a best-effort fetch (a failing fetch recovers from the last known origin state). A stale latest PENDING entry is marked INTERRUPTED before its replacement build is enqueued, because the executor queue does not survive a restart.
- Worktree cleanup deletes
.git/gittally/worktrees/<branchKey>directories of branches gone from origin (never those of queued or running builds) and then callsgit worktree prune. DurationParserwas extended withs/msuffixes forwatcher.pollInterval(it only knewd/h).- Watcher health is exposed via
Watcher.state(): WatcherState(running, last poll time, last fetch/poll error, queued branches) for the step 07 status endpoints; no events are published for it. - A UTC
Clockbean (WatcherConfiguration) makes slot matching and poll timestamps testable.