step 22b: repo context (#11)

* Step 22 B: RepoContext over the current repository

A RepoContext bundles a repository's primary checkout with the state that lives
inside or is keyed by it (results, artifact store) and carries its name. Today
there is exactly one, opened over the current working directory; the result and
artifact-store beans now come from it, so nothing else changes yet.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* Step 22 B: the watcher polls a RepoContext

start/poll/recoverOnStartup take the context instead of a working directory and
read results and artifacts from it; the per-repository poll memory (logged fetch
error, deprecation warning, cached branch definitions) moves into a RepoWatch
keyed by context, so the next session can iterate contexts without one
repository's outage silencing another's. The shared WatcherState is unchanged.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* Step 22 B: the executor runs builds of a RepoContext

startBuild takes the context first; builds serialize per (context, branch) and
share the global maxConcurrent cap across repositories, results and artifacts go
to the build's own context. ConsoleBuildRunner, the build/retry commands and the
builds API restart pass the current repository's context along.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* Step 22 B: the UI and the branch listing read their RepoContext

UiController and BuildsApiController take the current repository's context
instead of a settable working directory; BranchListing lists the branches of a
context and reads the results from it.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* Step 22 B: document the RepoContext, PR-doc for PR #11

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Michael Hönnig
2026-09-02 08:49:21 +02:00
committed by GitHub
co-authored by Claude Fable 5.1
parent 713edf77d6
commit 096cce3659
29 changed files with 571 additions and 293 deletions
+6 -2
View File
@@ -61,9 +61,13 @@ Three places must stay in sync when config keys change: the `WerkatorConfig` dat
`GitService` shells out to the `git` CLI via `GitCommandRunner` (a thin `ProcessBuilder` wrapper; no JGit). Commands that need repo information take it as a constructor dependency so tests can mock it. HTTPS fetches authenticate via a temporary, secret-free `GIT_ASKPASS` script (`GitAskPass`) with credentials from config passed through environment variables. `GitService` shells out to the `git` CLI via `GitCommandRunner` (a thin `ProcessBuilder` wrapper; no JGit). Commands that need repo information take it as a constructor dependency so tests can mock it. HTTPS fetches authenticate via a temporary, secret-free `GIT_ASKPASS` script (`GitAskPass`) with credentials from config passed through environment variables.
## Repository Context
Everything repository-scoped goes through a `RepoContext` (`repo` package, ADR 0009): the primary checkout (`workingDir`), the repository's `BuildResultRepository` (`.git/werkator/build-results.json`), its `ArtifactStore` (keyed by the repository path), and a short `name` defaulting to the directory basename — the future route segment. `RepoContexts.open(dir)` builds one; `RepoConfiguration` provides the single current-directory context as a bean, and the `BuildResultRepository`/`ArtifactStore` beans are that context's, so code that still injects them sees the same objects. Git access and config loading stay path-based services taking `repo.workingDir`. The context object is the identity (executor pools, watcher memory are keyed by it), so exactly one is opened per repository; the registry of step 22 session C opens one per entry. Not yet repository-scoped and left for that session: `StateDirMigration` (once per process on the cwd), the metrics collector's repository size, `ServerCommand`'s config, and `RunningBuild`, which carries no repository yet.
## Build Execution ## Build Execution
`BuildExecutor` runs builds asynchronously: up to `executor.maxConcurrent` branches concurrently (default 1), but never more than one build per branch at a time. Each branch builds in its own reusable git worktree at `.git/werkator/worktrees/<branchKey>` (`BranchWorkspaces`), checked out detached at the requested commit — the primary checkout is never used for builds. Status transitions are persisted via `BuildResultRepository` (JSON file under `.git/werkator/`), published to Gitea non-fatally, and emitted as `BuildStatusChangedEvent`s. Every run belongs to a named build definition (job, ADR 0007): the YAML `builds` section defines triggers (`onPush`, `atTimes`), branch selectors (`branches` globs, `activeWithin`), and build-setting overrides applied last over the merged branch config; the implicit `default` build (`onPush`, all branches) preserves the job-less behavior. Definitions are part of the branch layer — a branch may add and override its own, and they apply to that branch alone (its selectors are evaluated for it only) — while `executor.maxConcurrent` stays pinned. `BuildResult.build` records the job; restart, retry, and startup recovery re-run by that name, resolving settings from the *current* config. `BuildResult.name` — the pool, `<branch>@<build>` for non-default builds — keys everything display- and retention-side (repository grouping via `latestPerName`, retention pools, branches-view rows, permanent latest-green links), while `BuildResult.branch` keys everything git-side: origin lookups, gone-from-origin pruning, worktrees (every build runs in its branch's worktree, serialized per branch), and Gitea links/statuses. `branches.*.autoBuild` survives as a deprecated alias for a scheduled default-pool rebuild. Cancellation addresses a build by artifact key and terminates the whole process tree. Future code (watcher, server, UI) must not assume a single running build. `BuildExecutor` runs builds asynchronously: `startBuild(repo, branch, commit, build)` takes the `RepoContext` first; up to `executor.maxConcurrent` builds run concurrently across all repositories (default 1, sized once from the first build's config — an instance-level setting), but never more than one build per (repository, branch) at a time. Each branch builds in its own reusable git worktree at `.git/werkator/worktrees/<branchKey>` (`BranchWorkspaces`), checked out detached at the requested commit — the primary checkout is never used for builds. Status transitions are persisted in the build's own `RepoContext.results` (JSON file under that repository's `.git/werkator/`), published to Gitea non-fatally, and emitted as `BuildStatusChangedEvent`s. Every run belongs to a named build definition (job, ADR 0007): the YAML `builds` section defines triggers (`onPush`, `atTimes`), branch selectors (`branches` globs, `activeWithin`), and build-setting overrides applied last over the merged branch config; the implicit `default` build (`onPush`, all branches) preserves the job-less behavior. Definitions are part of the branch layer — a branch may add and override its own, and they apply to that branch alone (its selectors are evaluated for it only) — while `executor.maxConcurrent` stays pinned. `BuildResult.build` records the job; restart, retry, and startup recovery re-run by that name, resolving settings from the *current* config. `BuildResult.name` — the pool, `<branch>@<build>` for non-default builds — keys everything display- and retention-side (repository grouping via `latestPerName`, retention pools, branches-view rows, permanent latest-green links), while `BuildResult.branch` keys everything git-side: origin lookups, gone-from-origin pruning, worktrees (every build runs in its branch's worktree, serialized per branch), and Gitea links/statuses. `branches.*.autoBuild` survives as a deprecated alias for a scheduled default-pool rebuild. Cancellation addresses a build by artifact key and terminates the whole process tree. Future code (watcher, server, UI) must not assume a single running build.
On context close (e.g. systemd SIGTERM), a `ContextClosedEvent` listener in `BuildExecutor` terminates the process trees of all executing builds and waits (bounded) until their results are persisted as INTERRUPTED — a shutdown is never recorded as FAILED. Builds still queued stay PENDING and start no process. Both are re-enqueued by the watcher's startup recovery; INTERRUPTED therefore publishes as Gitea state `pending`, not `failure` (`GiteaStateMapping`). On context close (e.g. systemd SIGTERM), a `ContextClosedEvent` listener in `BuildExecutor` terminates the process trees of all executing builds and waits (bounded) until their results are persisted as INTERRUPTED — a shutdown is never recorded as FAILED. Builds still queued stay PENDING and start no process. Both are re-enqueued by the watcher's startup recovery; INTERRUPTED therefore publishes as Gitea state `pending`, not `failure` (`GiteaStateMapping`).
@@ -73,7 +77,7 @@ The runtime is selected per build behind the `BuildRunner` interface: `Dispatchi
## Watcher ## Watcher
`Watcher` replaces the legacy blocking main loop with a non-blocking fixed-delay poll cycle: fetch origin, enqueue due branches (changed local, recent new origin, due auto-build slots) via `BuildExecutor`, then prune results, artifacts, and stale worktrees. Branches whose build has `requirePullRequest` are enqueued only while their head commit matches a pull-request head, detected without an API token by listing `refs/pull/*/head` via `git ls-remote` (lazily, at most once per poll cycle); manual `build` commands bypass this gate, and `watcher.pullRequestGate: false` disables it globally for plain-git origins without pull-request refs. Which builds are due is decided per branch from that branch's own definitions (`definitionsFor`): the primary config with the branch's committed `.werkator.yml` merged on top, cached per branch by its head commit *and* the primary config it was merged with, so the `git show` runs only when the branch moved while an edited machine or project config still takes effect on the next poll, and falling back to the primary definitions when that config is unreadable. Nothing is scheduled until `Watcher.start()` is called explicitly (server/watch mode) — CLI commands and tests never start the loop. "Already built" is tracked via the result repository, not by moving local branch refs. `Watcher` replaces the legacy blocking main loop with a non-blocking fixed-delay poll cycle over a `RepoContext` (`start(repo)`, `poll(repo)`, `recoverOnStartup(repo)`; what it remembers per repository — the logged fetch error, the deprecation warning, the cached branch definitions — lives in a `RepoWatch` keyed by context, while `WatcherState` is still one per instance): fetch origin, enqueue due branches (changed local, recent new origin, due auto-build slots) via `BuildExecutor`, then prune results, artifacts, and stale worktrees. Branches whose build has `requirePullRequest` are enqueued only while their head commit matches a pull-request head, detected without an API token by listing `refs/pull/*/head` via `git ls-remote` (lazily, at most once per poll cycle); manual `build` commands bypass this gate, and `watcher.pullRequestGate: false` disables it globally for plain-git origins without pull-request refs. Which builds are due is decided per branch from that branch's own definitions (`definitionsFor`): the primary config with the branch's committed `.werkator.yml` merged on top, cached per branch by its head commit *and* the primary config it was merged with, so the `git show` runs only when the branch moved while an edited machine or project config still takes effect on the next poll, and falling back to the primary definitions when that config is unreadable. Nothing is scheduled until `Watcher.start()` is called explicitly (server/watch mode) — CLI commands and tests never start the loop. "Already built" is tracked via the result repository, not by moving local branch refs.
After the enqueue decision — and only after it, because a local ref lagging behind origin *is* the change signal — the cycle fast-forwards the primary checkout's local branch refs to their origin counterparts (`watcher.fastForwardLocalRefs`, `GitService.fastForwardLocalBranches`), so build tools reading the shared `.git` from a worktree see the refs they expect; diverged or ahead branches are never touched. Auto-build slot state lives in `.git/werkator/auto-builds.json`; watcher health is exposed via `Watcher.state()`. After the enqueue decision — and only after it, because a local ref lagging behind origin *is* the change signal — the cycle fast-forwards the primary checkout's local branch refs to their origin counterparts (`watcher.fastForwardLocalRefs`, `GitService.fastForwardLocalBranches`), so build tools reading the shared `.git` from a worktree see the refs they expect; diverged or ahead branches are never touched. Auto-build slot state lives in `.git/werkator/auto-builds.json`; watcher health is exposed via `Watcher.state()`.
## System Metrics ## System Metrics
+2 -1
View File
@@ -30,13 +30,14 @@ IMPORTANT: Before designing or modifying code in any production package, load th
### Package Structure ### Package Structure
All production code lives under `de.hoennig.werkator`, with sub-packages `commands` (picocli subcommands), `config` (YAML config loading and schema), `git` (git CLI access), `gitea` (Gitea commit-status API client), `build` (build execution, results, workspaces), `artifacts` (filesystem artifact store), `watcher` (branch polling, auto-builds, startup recovery), `metrics` (system resource sampling and aggregation), and `server` (JSON API controllers, Thymeleaf UI, artifact serving, control token, watcher and metrics lifecycles). Tests mirror this structure under `src/test/kotlin`. All production code lives under `de.hoennig.werkator`, with sub-packages `commands` (picocli subcommands), `config` (YAML config loading and schema), `git` (git CLI access), `gitea` (Gitea commit-status API client), `build` (build execution, results, workspaces), `artifacts` (filesystem artifact store), `repo` (the `RepoContext` a repository is worked on through: checkout, results, artifact store, name), `watcher` (branch polling, auto-builds, startup recovery), `metrics` (system resource sampling and aggregation), and `server` (JSON API controllers, Thymeleaf UI, artifact serving, control token, watcher and metrics lifecycles). Tests mirror this structure under `src/test/kotlin`.
### Hard Invariants ### Hard Invariants
- `exitProcess` is called only from `main()` — never inside `CliRunner.run()`; this keeps the Spring context alive during tests. - `exitProcess` is called only from `main()` — never inside `CliRunner.run()`; this keeps the Spring context alive during tests.
- Nothing is scheduled during CLI runs or tests: the watcher poll loop and metrics sampling start only via an explicit `start()` in the `server` profile. - Nothing is scheduled during CLI runs or tests: the watcher poll loop and metrics sampling start only via an explicit `start()` in the `server` profile.
- Builds run detached in worktrees under `.git/werkator/worktrees/<branchKey>`; the primary checkout is never used for builds; never assume a single running build. - Builds run detached in worktrees under `.git/werkator/worktrees/<branchKey>`; the primary checkout is never used for builds; never assume a single running build.
- Everything repository-scoped (results, artifacts, worktrees, git and config access) goes through a `RepoContext`, never through an implicit current directory: the executor serializes per (context, branch) under one global `maxConcurrent`, the watcher polls a context. Today exactly one context exists, the current working directory; the registry (step 22 session C) opens one per entry.
- When config keys change, three places must stay in sync: the `WerkatorConfig` data classes, the `InitCommand` templates, and `docs/configuration.md`. - When config keys change, three places must stay in sync: the `WerkatorConfig` data classes, the `InitCommand` templates, and `docs/configuration.md`.
- Every config file may declare `werkator.version.since`/`below` (the Werkator it is written for, never a format version — no API is involved). `since` is enforced in both directions, using `ConfigVersions.FORMAT_BROKE_IN` for "file predates a breaking change"; `below` only warns. A violation aborts the start for the machine and project config, but fails only that branch's builds for a branch config. - Every config file may declare `werkator.version.since`/`below` (the Werkator it is written for, never a format version — no API is involved). `since` is enforced in both directions, using `ConfigVersions.FORMAT_BROKE_IN` for "file predates a breaking change"; `below` only warns. A violation aborts the start for the machine and project config, but fails only that branch's builds for a branch config.
- A branch describes its own CI: its committed `.werkator.yml` is the branch layer (`ConfigLoader.loadWithBranchLayer`, used by the watcher per origin branch and by `loadForWorktree` at build time) and takes precedence over `.git`/project — including the whole `builds` section, so a new configuration can be tried out on a branch without affecting other branches. Only the pinned set is stripped from that layer: secrets (`git`), host/repository sections (`server`, `gitea`, `executor`, `watcher`), the docker (`docker.enabled`, `docker.network`) and bubblewrap (`bwrap.enabled`, `bwrap.rootfs`, `bwrap.werkdock`) sandbox policies, and the trust gate (`requirePullRequest`). A branch must never reach credentials, disable its container or sandbox, change its network, substitute a foreign rootfs, raise global concurrency, or bypass its own pull-request gate; a branch's definitions apply to that branch alone. - A branch describes its own CI: its committed `.werkator.yml` is the branch layer (`ConfigLoader.loadWithBranchLayer`, used by the watcher per origin branch and by `loadForWorktree` at build time) and takes precedence over `.git`/project — including the whole `builds` section, so a new configuration can be tried out on a branch without affecting other branches. Only the pinned set is stripped from that layer: secrets (`git`), host/repository sections (`server`, `gitea`, `executor`, `watcher`), the docker (`docker.enabled`, `docker.network`) and bubblewrap (`bwrap.enabled`, `bwrap.rootfs`, `bwrap.werkdock`) sandbox policies, and the trust gate (`requirePullRequest`). A branch must never reach credentials, disable its container or sandbox, change its network, substitute a foreign rootfs, raise global concurrency, or bypass its own pull-request gate; a branch's definitions apply to that branch alone.
+6 -5
View File
@@ -48,9 +48,10 @@ The pinning model is untouched: pinned keys still come from each repo's machine
### B — RepoContext refactor, behavior unchanged ### B — RepoContext refactor, behavior unchanged
- Introduce a `RepoContext` (working dir, config loading, git access, result repository, artifact store key, watcher state) and thread it through executor, watcher, and server code paths that today implicitly use the single `workingDir`. - ~~Introduce a `RepoContext` (working dir, config loading, git access, result repository, artifact store key, watcher state) and thread it through executor, watcher, and server code paths that today implicitly use the single `workingDir`.~~ — done 2026-09-02 (PR #11): `RepoContext` (`repo` package) carries `name`, `workingDir`, `results`, `artifactStore`; git access and config loading stay path-based services taking `repo.workingDir` (the home `defaults:` layer of session C is the moment config loading needs the context). The watcher's per-repo memory lives in a `RepoWatch` keyed by context; `WatcherState` stays one per instance until session C.
- The executor becomes instance-global with repo-scoped pools: serialization per (repo, branch), the global `maxConcurrent` across repos; `BuildResult` needs no schema change — results stay in each repo's own JSON file, the repo dimension exists only in memory and in routes. - ~~The executor becomes instance-global with repo-scoped pools: serialization per (repo, branch), the global `maxConcurrent` across repos; `BuildResult` needs no schema change — results stay in each repo's own JSON file, the repo dimension exists only in memory and in routes.~~ — done 2026-09-02: `startBuild(repo, branch, commit, build)`, pools keyed by (context, branch), one semaphore.
- Single-repo behavior, routes, and UI stay byte-identical; the full test suite is the acceptance gate. - ~~Single-repo behavior, routes, and UI stay byte-identical; the full test suite is the acceptance gate.~~ — done: no route, template, or config change; the current-directory context is a bean and the result/artifact-store beans are its members.
- Carried over to session C (found while threading): `StateDirMigration` runs once per process on the cwd and must run per registered repo; `SystemMetricsCollector` measures the cwd's repository size; `ServerCommand` reads `server.*` from the cwd; `RunningBuild` carries no repository, so `BuildExecutor.currentBuilds()` and the watcher's worktree pruning cannot tell repos apart yet (harmless today: at worst a worktree of another repo's branch name is kept one cycle longer).
### C — The registry and N repositories ### C — The registry and N repositories
@@ -75,12 +76,12 @@ The pinning model is untouched: pinned keys still come from each repo's machine
- Fairness across repos when the global concurrency cap is contended (round-robin per repo vs. FIFO) — decide in session C with the real queue behavior at hand. - Fairness across repos when the global concurrency cap is contended (round-robin per repo vs. FIFO) — decide in session C with the real queue behavior at hand.
- Whether buildenv rootfs trees should be shared across repos (today each repo unpacks its own under `.git/werkator/buildenv/`) — the natural answer is Werkdock's image store (step 21 session C), not instance-level state; until then duplicate unpacked rootfs trees are the accepted cost. - Whether buildenv rootfs trees should be shared across repos (today each repo unpacks its own under `.git/werkator/buildenv/`) — the natural answer is Werkdock's image store (step 21 session C), not instance-level state; until then duplicate unpacked rootfs trees are the accepted cost.
- Whether `artifactKey` needs a repo prefix or stays globally unique by construction (random suffix) — decide in session B when the routes are designed. - ~~Whether `artifactKey` needs a repo prefix or stays globally unique by construction (random suffix) — decide in session B when the routes are designed.~~ — decided 2026-09-02: no prefix. The key is derived from pool name and start time, and both the results file and the artifact store are per repository, so it only ever has to be unique within one; the repo dimension enters through the route segment in session D, never through the key. A prefix would also change every existing artifact directory name.
## Acceptance Criteria ## Acceptance Criteria
- Session A: ADR 0009 written (done 2026-09-01); the registry and key ownership land in `docs/configuration.md` together with the implementing sessions, since that reference describes implemented configuration only. - Session A: ADR 0009 written (done 2026-09-01); the registry and key ownership land in `docs/configuration.md` together with the implementing sessions, since that reference describes implemented configuration only.
- Session B: full suite green with `RepoContext` threaded through; no route or behavior change observable. - ~~Session B: full suite green with `RepoContext` threaded through; no route or behavior change observable.~~ — done 2026-09-02.
- Session C: an instance with two registered repos builds pushes in both, with per-repo error isolation proven by a test (one broken origin, the other keeps building). - Session C: an instance with two registered repos builds pushes in both, with per-repo error isolation proven by a test (one broken origin, the other keeps building).
- Session D: both repos browsable in one UI; single-repo installations keep their existing URLs. - Session D: both repos browsable in one UI; single-repo installations keep their existing URLs.
- Session E: mih34 builds Werkator and Werkbaum from one service; `docs/deployment.md` describes the registry setup. - Session E: mih34 builds Werkator and Werkbaum from one service; `docs/deployment.md` describes the registry setup.
+97
View File
@@ -0,0 +1,97 @@
> **WARNING:** This document describes only the change applied in this PR.
> It may already be outdated once the next PR is merged.
> Historic PR-documentation is not maintained along with new PRs — treat it as a snapshot, not as current documentation.
## The Problem
ADR 0009 (PR #10) decided that one Werkator instance serves a set of repositories, but the code assumes a single one everywhere: the executor, the watcher, the commands, and the controllers resolve results, artifacts, worktrees, and git access through an implicit working directory, and the result repository and artifact store are context-wide beans.
A registry of repositories cannot be threaded through that — every code path would have to learn a `workingDir` parameter it does not have and a results file it cannot pick.
Step 22 session B is the behavior-preserving refactor that gives those paths one explicit handle to a repository, so that sessions C and D only have to open more of them and put a name on the routes.
## Non-Goals
- The registry, the home `~/.werkator.yml`, and N repositories (session C).
- Repository-scoped routes, API paths, or UI grouping (session D) — every route, template, and JSON shape is unchanged.
- Any configuration change; `docs/configuration.md` is untouched.
## The Scenarios
### Feature: one explicit handle per repository
#### Background
- A `RepoContext` bundles what is repository-scoped: the primary checkout, the repository's results file, its artifact store, and a short name (the directory basename by default) meant for display and, later, routes.
- The context object is the identity: executor pools and the watcher's memory are keyed by it, so exactly one is opened per repository.
#### Scenario#11.01: Builds are serialized per repository and branch under one global cap
So that two repositories in one instance never build the same branch name in each other's worktree, while the instance-level `executor.maxConcurrent` stays the only concurrency limit.
- **Given** the executor and a `RepoContext`
- **When** `startBuild(repo, branch, commit, build)` is called
- **Then** the PENDING result is written to that context's results and the artifacts persist to that context's store
- **and** a second build of the same branch in the same context waits for the first, while other branches run concurrently up to the global cap
- **and** a duplicate is only detected within the same context.
##### Verified by
- [BuildExecutorTest](../../src/test/kotlin/de/hoennig/werkator/build/BuildExecutorTest.kt) (the existing serialization, concurrency, and duplicate tests, now over a context)
- [BuildExecutorArtifactIntegrationTest](../../src/test/kotlin/de/hoennig/werkator/artifacts/BuildExecutorArtifactIntegrationTest.kt)
#### Scenario#11.02: The watcher polls a repository context and keeps its memory per repository
So that the next session can iterate contexts in one cycle without one repository's fetch outage silencing another's log or cache.
- **Given** the watcher and a `RepoContext`
- **When** `start(repo)`, `poll(repo)`, or `recoverOnStartup(repo)` runs
- **Then** results, artifacts, auto-build slots, and worktrees are those of the context
- **and** the logged fetch error, the `autoBuild` deprecation warning, and the cached branch definitions are remembered per context.
##### Verified by
- [WatcherTest](../../src/test/kotlin/de/hoennig/werkator/watcher/WatcherTest.kt) (every existing poll, recovery, and prune test, now over a context)
- [ServerModeApplicationTest](../../src/test/kotlin/de/hoennig/werkator/ServerModeApplicationTest.kt) (the server profile starts the watcher over the served repository)
#### Scenario#11.03: A single-repository installation behaves exactly as before
So that no route, file location, or display changes for existing installations.
- **Given** no registry (there is none yet)
- **When** the CLI or the server starts in a repository
- **Then** the current working directory is the one context, named after its directory
- **and** the result and artifact-store beans are that context's members, so `status`, the JSON API, and the UI read the same files as before.
##### Verified by
- [RepoContextsTest](../../src/test/kotlin/de/hoennig/werkator/repo/RepoContextsTest.kt)
- the unchanged controller, command, and integration tests of the full suite
## The Solution
`RepoContext` (`repo` package) is a plain class with `name`, `workingDir`, `results`, and `artifactStore`; `RepoContexts.open(dir)` builds one over `.git/werkator/build-results.json` and a `FileArtifactStore` keyed by the path, and `RepoConfiguration` provides the current directory as the single bean.
`BuildExecutor.startBuild` takes the context first, keeps its per-branch serial workers in a map keyed by `(context, branch)`, and writes results and artifacts through the build's own context; the semaphore stays one per executor, since the cap is instance-level per ADR 0009.
`Watcher.start/poll/recoverOnStartup` take the context, and the three mutable per-repository fields moved into a `RepoWatch` keyed by context; the observable `WatcherState` is untouched.
`ConsoleBuildRunner`, `BuildCommand`, `RetryCommand`, `BuildsApiController`, `UiController`, and `BranchListing` lost their settable `workingDir` in favor of the injected context.
Git access and config loading stay path-based services taking `repo.workingDir`: the home `defaults:` layer of session C is the point where config loading needs the context, and it was not built ahead of that need.
The open `artifactKey` question is decided against a repository prefix: the results file and the artifact store are per repository, so the key only has to be unique within one, and the repo dimension will enter through the route segment.
## Open Questions
- `RunningBuild` carries no repository, so `currentBuilds()` and the watcher's worktree pruning cannot tell repositories apart yet — harmless with one context, listed for session C in the plan.
- `StateDirMigration`, the metrics collector's repository size, and `ServerCommand`'s config still read the current directory — instance-level or per-registry-entry concerns, deferred to session C.
## Additional Changes
- Architecture skill: new "Repository Context" section; the executor and watcher paragraphs describe the context-based signatures.
- AGENTS.md: `repo` in the package list and a hard invariant that repository-scoped state goes through a `RepoContext`.
- `docs/plan/22-multi-repo.md`: session B ticked with the carry-overs to session C, the `artifactKey` question decided.
## Prerequisite PRs
- PR #10 (ADR 0009 and the step 22 roadmap).
## Follow-up PRs
- Session C: the registry and N repositories, watcher multiplexing.
- Session D: server/API/UI repo scoping.
- Session E: rollout on mih34 with Werkbaum.
@@ -1,17 +1,13 @@
package de.hoennig.werkator.artifacts package de.hoennig.werkator.artifacts
import de.hoennig.werkator.build.ArtifactStore import de.hoennig.werkator.build.ArtifactStore
import de.hoennig.werkator.config.ConfigLoader import de.hoennig.werkator.repo.RepoContext
import org.springframework.context.annotation.Bean import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration import org.springframework.context.annotation.Configuration
@Configuration @Configuration
class ArtifactsConfiguration { class ArtifactsConfiguration {
/** /** The current repository's artifact store, for the code paths that still take the store bean. */
* Store relative to the working directory, matching how `ConfigLoader` and the
* `BuildResultRepository` bean resolve their files. Nothing is touched until the
* first build persists, so the bean is safe outside a git repository.
*/
@Bean @Bean
fun artifactStore(configLoader: ConfigLoader): ArtifactStore = FileArtifactStore(configLoader) fun artifactStore(repo: RepoContext): ArtifactStore = repo.artifactStore
} }
@@ -1,16 +1,12 @@
package de.hoennig.werkator.build package de.hoennig.werkator.build
import de.hoennig.werkator.repo.RepoContext
import org.springframework.context.annotation.Bean import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration import org.springframework.context.annotation.Configuration
import java.nio.file.Paths
@Configuration @Configuration
class BuildConfiguration { class BuildConfiguration {
/** /** The current repository's results, for the code paths that still take the repository bean. */
* Results file relative to the working directory, matching how `ConfigLoader`
* resolves the `.git/werkator/` override file. Nothing is touched until the
* first build runs, so the bean is safe outside a git repository.
*/
@Bean @Bean
fun buildResultRepository(): BuildResultRepository = FileBuildResultRepository(Paths.get(".git/werkator/build-results.json")) fun buildResultRepository(repo: RepoContext): BuildResultRepository = repo.results
} }
@@ -4,6 +4,7 @@ import de.hoennig.werkator.config.BranchConfig
import de.hoennig.werkator.config.BuildDefinition import de.hoennig.werkator.config.BuildDefinition
import de.hoennig.werkator.config.ConfigLoader import de.hoennig.werkator.config.ConfigLoader
import de.hoennig.werkator.gitea.GiteaClient import de.hoennig.werkator.gitea.GiteaClient
import de.hoennig.werkator.repo.RepoContext
import org.slf4j.LoggerFactory import org.slf4j.LoggerFactory
import org.springframework.context.ApplicationEventPublisher import org.springframework.context.ApplicationEventPublisher
import org.springframework.context.event.ContextClosedEvent import org.springframework.context.event.ContextClosedEvent
@@ -14,7 +15,6 @@ import java.io.InputStream
import java.io.OutputStream import java.io.OutputStream
import java.nio.file.Files import java.nio.file.Files
import java.nio.file.Path import java.nio.file.Path
import java.nio.file.Paths
import java.nio.file.StandardOpenOption import java.nio.file.StandardOpenOption
import java.time.Duration import java.time.Duration
import java.time.Instant import java.time.Instant
@@ -26,31 +26,30 @@ import java.util.concurrent.atomic.AtomicBoolean
import kotlin.concurrent.thread import kotlin.concurrent.thread
/** /**
* Runs builds asynchronously: up to `executor.maxConcurrent` branches at the same time * Runs builds asynchronously: up to `executor.maxConcurrent` builds at the same time
* (default 1), but never more than one build per branch. Each branch builds in its * across all repositories (default 1), but never more than one build per branch of
* own git worktree via [BranchWorkspaces], never in the primary checkout. * a repository. Each branch builds in its own git worktree via [BranchWorkspaces],
* Every status transition is persisted via the [BuildResultRepository], published * never in the primary checkout. Every status transition is persisted in the
* to Gitea (non-fatal), and emitted as a [BuildStatusChangedEvent]. * build's [RepoContext.results], published to Gitea (non-fatal), and emitted as a
* [BuildStatusChangedEvent].
*/ */
@Service @Service
class BuildExecutor( class BuildExecutor(
private val repository: BuildResultRepository,
private val configLoader: ConfigLoader, private val configLoader: ConfigLoader,
private val giteaClient: GiteaClient, private val giteaClient: GiteaClient,
private val buildRunner: BuildRunner, private val buildRunner: BuildRunner,
private val workspaces: BranchWorkspaces, private val workspaces: BranchWorkspaces,
private val artifactStore: ArtifactStore,
private val eventPublisher: ApplicationEventPublisher, private val eventPublisher: ApplicationEventPublisher,
) { ) {
private val log = LoggerFactory.getLogger(BuildExecutor::class.java) private val log = LoggerFactory.getLogger(BuildExecutor::class.java)
/** One serial worker per branch enforces at most one build per branch. */ /** One serial worker per (repository, branch) enforces at most one build per branch of a repository. */
private val branchWorkers = ConcurrentHashMap<String, ExecutorService>() private val branchWorkers = ConcurrentHashMap<Pair<RepoContext, String>, ExecutorService>()
/** All accepted, not yet finished builds by artifact key — queued and running. */ /** All accepted, not yet finished builds by artifact key — queued and running. */
private val builds = ConcurrentHashMap<String, ActiveBuild>() private val builds = ConcurrentHashMap<String, ActiveBuild>()
/** Global concurrency limit; sized from `executor.maxConcurrent` on first use. */ /** Global concurrency limit across all repositories; sized from `executor.maxConcurrent` on first use. */
@Volatile @Volatile
private var slots: Semaphore? = null private var slots: Semaphore? = null
@@ -61,9 +60,10 @@ class BuildExecutor(
fun currentBuilds(): List<RunningBuild> = builds.values.filter { it.running }.map { it.runningBuild } fun currentBuilds(): List<RunningBuild> = builds.values.filter { it.running }.map { it.runningBuild }
/** /**
* Persists a PENDING result and queues the build; returns immediately. * Persists a PENDING result in [repo] and queues the build; returns immediately.
* A build of the same branch waits until the branch's previous build finished; * A build of the same branch waits until the branch's previous build finished;
* builds of other branches run concurrently while slots are free. * builds of other branches — of this or any other repository — run concurrently
* while slots are free.
* While a build of the same branch and commit is already queued or executing (and * While a build of the same branch and commit is already queued or executing (and
* not cancel-requested), that build is returned instead of stacking a duplicate — * not cancel-requested), that build is returned instead of stacking a duplicate —
* a double-triggered UI restart must not queue the same commit twice. Re-running * a double-triggered UI restart must not queue the same commit twice. Re-running
@@ -76,15 +76,16 @@ class BuildExecutor(
* worktree, serialized with the branch's other builds. * worktree, serialized with the branch's other builds.
*/ */
fun startBuild( fun startBuild(
repo: RepoContext,
branch: String, branch: String,
commit: String, commit: String,
workingDir: Path = Paths.get("."),
build: String = BuildDefinition.DEFAULT, build: String = BuildDefinition.DEFAULT,
): RunningBuild { ): RunningBuild {
val name = BuildDefinition.poolName(branch, build) val name = BuildDefinition.poolName(branch, build)
val duplicate = val duplicate =
builds.values.firstOrNull { builds.values.firstOrNull {
!it.cancelled.get() && !it.cancelled.get() &&
it.repo === repo &&
it.runningBuild.name == name && it.runningBuild.name == name &&
it.runningBuild.commit == commit it.runningBuild.commit == commit
} }
@@ -114,13 +115,13 @@ class BuildExecutor(
duration = null, duration = null,
artifactKey = runningBuild.artifactKey, artifactKey = runningBuild.artifactKey,
) )
repository.append(pending) repo.results.append(pending)
eventPublisher.publishEvent(BuildStatusChangedEvent(pending)) eventPublisher.publishEvent(BuildStatusChangedEvent(pending))
val activeBuild = ActiveBuild(runningBuild, workingDir) val activeBuild = ActiveBuild(runningBuild, repo)
builds[runningBuild.artifactKey] = activeBuild builds[runningBuild.artifactKey] = activeBuild
publishGiteaStatus(activeBuild, BuildStatus.PENDING, duration = null) publishGiteaStatus(activeBuild, BuildStatus.PENDING, duration = null)
branchWorkers branchWorkers
.computeIfAbsent(branch) { serialWorker(it) } .computeIfAbsent(repo to branch) { serialWorker(branch) }
.submit { execute(activeBuild) } .submit { execute(activeBuild) }
return runningBuild return runningBuild
} }
@@ -171,7 +172,7 @@ class BuildExecutor(
var finalStatus: BuildStatus? = BuildStatus.FAILED var finalStatus: BuildStatus? = BuildStatus.FAILED
var workspace: Path? = null var workspace: Path? = null
try { try {
slot = slotsFor(build.workingDir) slot = slotsFor(build.repo.workingDir)
slot.acquire() slot.acquire()
if (build.cancelled.get()) { if (build.cancelled.get()) {
finalStatus = BuildStatus.CANCELLED finalStatus = BuildStatus.CANCELLED
@@ -188,7 +189,7 @@ class BuildExecutor(
workspaces.prepare( workspaces.prepare(
branch = build.runningBuild.branch, branch = build.runningBuild.branch,
commit = build.runningBuild.commit, commit = build.runningBuild.commit,
repoDir = build.workingDir, repoDir = build.repo.workingDir,
) )
workspace = preparedWorkspace workspace = preparedWorkspace
val exitCode = runBuildCommands(build, preparedWorkspace) val exitCode = runBuildCommands(build, preparedWorkspace)
@@ -220,7 +221,7 @@ class BuildExecutor(
val duration = build.runningBuild.runningSince?.let { Duration.between(it, Instant.now()) } val duration = build.runningBuild.runningSince?.let { Duration.between(it, Instant.now()) }
val result = transition(build, finalStatus, duration) val result = transition(build, finalStatus, duration)
try { try {
artifactStore.persist(result, build.runningBuild.stagingDir, workspace) build.repo.artifactStore.persist(result, build.runningBuild.stagingDir, workspace)
} catch (e: Exception) { } catch (e: Exception) {
log.warn("could not persist artifacts of {}: {}", result.artifactKey, e.message) log.warn("could not persist artifacts of {}: {}", result.artifactKey, e.message)
} }
@@ -231,8 +232,9 @@ class BuildExecutor(
} }
/** /**
* The semaphore is sized once from the first build's config; * The semaphore is sized once from the first build's config — the global cap is an
* changing `executor.maxConcurrent` requires a restart. * instance-level setting (ADR 0009) and does not vary by repository; changing
* `executor.maxConcurrent` requires a restart.
*/ */
private fun slotsFor(workingDir: Path): Semaphore { private fun slotsFor(workingDir: Path): Semaphore {
slots?.let { return it } slots?.let { return it }
@@ -256,7 +258,7 @@ class BuildExecutor(
build: ActiveBuild, build: ActiveBuild,
workspace: Path, workspace: Path,
): Int { ): Int {
val branchConfig = buildConfig(build.runningBuild, build.workingDir, workspace) val branchConfig = buildConfig(build.runningBuild, build.repo.workingDir, workspace)
val buildCommand = branchConfig.buildCommand val buildCommand = branchConfig.buildCommand
val stagingDir = build.runningBuild.stagingDir val stagingDir = build.runningBuild.stagingDir
Files.newOutputStream(stagingDir.resolve(branchConfig.stdoutLog)).use { stdoutLog -> Files.newOutputStream(stagingDir.resolve(branchConfig.stdoutLog)).use { stdoutLog ->
@@ -293,7 +295,7 @@ class BuildExecutor(
command = command, command = command,
workingDir = workspace, workingDir = workspace,
environment = mapOf("branch" to build.runningBuild.branch), environment = mapOf("branch" to build.runningBuild.branch),
repoDir = build.workingDir, repoDir = build.repo.workingDir,
branchConfig = branchConfig, branchConfig = branchConfig,
onAuxProcess = { aux -> onAuxProcess = { aux ->
// preparation phases (e.g. a Docker image build) must die on cancellation // preparation phases (e.g. a Docker image build) must die on cancellation
@@ -363,7 +365,7 @@ class BuildExecutor(
): BuildResult { ): BuildResult {
val runningBuild = build.runningBuild val runningBuild = build.runningBuild
val updated = val updated =
repository.updateByArtifactKey(runningBuild.artifactKey) { build.repo.results.updateByArtifactKey(runningBuild.artifactKey) {
it.copy( it.copy(
status = status, status = status,
runningSince = runningBuild.runningSince ?: it.runningSince, runningSince = runningBuild.runningSince ?: it.runningSince,
@@ -378,7 +380,7 @@ class BuildExecutor(
runningSince = runningBuild.runningSince, runningSince = runningBuild.runningSince,
duration = duration, duration = duration,
artifactKey = runningBuild.artifactKey, artifactKey = runningBuild.artifactKey,
).also { repository.append(it) } ).also { build.repo.results.append(it) }
eventPublisher.publishEvent(BuildStatusChangedEvent(updated)) eventPublisher.publishEvent(BuildStatusChangedEvent(updated))
publishGiteaStatus(build, status, duration) publishGiteaStatus(build, status, duration)
return updated return updated
@@ -395,7 +397,7 @@ class BuildExecutor(
status = status, status = status,
description = description(status, duration), description = description(status, duration),
targetUrl = null, targetUrl = null,
workingDir = build.workingDir, workingDir = build.repo.workingDir,
// from the primary config, not the worktree: statusContext is pinned, so a // from the primary config, not the worktree: statusContext is pinned, so a
// branch cannot report under a check name it was not given // branch cannot report under a check name it was not given
context = statusContextOf(build), context = statusContextOf(build),
@@ -409,7 +411,7 @@ class BuildExecutor(
private fun statusContextOf(build: ActiveBuild): String = private fun statusContextOf(build: ActiveBuild): String =
try { try {
configLoader configLoader
.load(build.workingDir) .load(build.repo.workingDir)
.buildSettings(build.runningBuild.branch, build.runningBuild.build) .buildSettings(build.runningBuild.branch, build.runningBuild.build)
.statusContext .statusContext
} catch (e: Exception) { } catch (e: Exception) {
@@ -496,7 +498,7 @@ class BuildExecutor(
private class ActiveBuild( private class ActiveBuild(
val runningBuild: RunningBuild, val runningBuild: RunningBuild,
val workingDir: Path, val repo: RepoContext,
) { ) {
val cancelled = AtomicBoolean(false) val cancelled = AtomicBoolean(false)
@@ -2,12 +2,12 @@ package de.hoennig.werkator.commands
import de.hoennig.werkator.build.BuildStatus import de.hoennig.werkator.build.BuildStatus
import de.hoennig.werkator.git.GitService import de.hoennig.werkator.git.GitService
import de.hoennig.werkator.repo.RepoContext
import org.springframework.stereotype.Component import org.springframework.stereotype.Component
import picocli.CommandLine.Command import picocli.CommandLine.Command
import picocli.CommandLine.ExitCode import picocli.CommandLine.ExitCode
import picocli.CommandLine.Parameters import picocli.CommandLine.Parameters
import java.nio.file.Path import java.nio.file.Path
import java.nio.file.Paths
import java.util.concurrent.Callable import java.util.concurrent.Callable
/** /**
@@ -24,6 +24,8 @@ import java.util.concurrent.Callable
class BuildCommand( class BuildCommand(
private val gitService: GitService, private val gitService: GitService,
private val consoleBuildRunner: ConsoleBuildRunner, private val consoleBuildRunner: ConsoleBuildRunner,
/** The repository to build: the current working directory (a repo selector comes with the registry). */
var repo: RepoContext,
) : Callable<Int> { ) : Callable<Int> {
@Parameters( @Parameters(
index = "0", index = "0",
@@ -33,7 +35,8 @@ class BuildCommand(
) )
var branchFragment: String? = null var branchFragment: String? = null
var workingDir: Path = Paths.get(".") private val workingDir: Path
get() = repo.workingDir
override fun call(): Int { override fun call(): Int {
val branch: String val branch: String
@@ -47,7 +50,7 @@ class BuildCommand(
return ExitCode.USAGE return ExitCode.USAGE
} }
println("building branch $branch at commit ${commit.take(12)}") println("building branch $branch at commit ${commit.take(12)}")
val status = consoleBuildRunner.buildAndStream(branch, commit, workingDir) val status = consoleBuildRunner.buildAndStream(repo, branch, commit)
return if (status == BuildStatus.SUCCESS) ExitCode.OK else ExitCode.SOFTWARE return if (status == BuildStatus.SUCCESS) ExitCode.OK else ExitCode.SOFTWARE
} }
@@ -1,12 +1,11 @@
package de.hoennig.werkator.commands package de.hoennig.werkator.commands
import de.hoennig.werkator.build.ArtifactStore
import de.hoennig.werkator.build.BuildExecutor import de.hoennig.werkator.build.BuildExecutor
import de.hoennig.werkator.build.BuildResult import de.hoennig.werkator.build.BuildResult
import de.hoennig.werkator.build.BuildResultRepository
import de.hoennig.werkator.build.BuildStatus import de.hoennig.werkator.build.BuildStatus
import de.hoennig.werkator.build.RunningBuild import de.hoennig.werkator.build.RunningBuild
import de.hoennig.werkator.config.BuildDefinition import de.hoennig.werkator.config.BuildDefinition
import de.hoennig.werkator.repo.RepoContext
import de.hoennig.werkator.server.UiFormats import de.hoennig.werkator.server.UiFormats
import org.springframework.stereotype.Component import org.springframework.stereotype.Component
import java.io.IOException import java.io.IOException
@@ -14,7 +13,6 @@ import java.nio.channels.Channels
import java.nio.channels.FileChannel import java.nio.channels.FileChannel
import java.nio.file.Files import java.nio.file.Files
import java.nio.file.Path import java.nio.file.Path
import java.nio.file.Paths
import java.nio.file.StandardOpenOption import java.nio.file.StandardOpenOption
import java.time.Duration import java.time.Duration
@@ -26,31 +24,29 @@ import java.time.Duration
@Component @Component
class ConsoleBuildRunner( class ConsoleBuildRunner(
private val buildExecutor: BuildExecutor, private val buildExecutor: BuildExecutor,
private val repository: BuildResultRepository,
private val artifactStore: ArtifactStore,
) { ) {
var pollIntervalMillis = 200L var pollIntervalMillis = 200L
var persistTimeoutMillis = 30_000L var persistTimeoutMillis = 30_000L
/** Builds [branch] at [commit], blocking until the build finished; returns the final status. */ /** Builds [branch] of [repo] at [commit], blocking until the build finished; returns the final status. */
fun buildAndStream( fun buildAndStream(
repo: RepoContext,
branch: String, branch: String,
commit: String, commit: String,
workingDir: Path = Paths.get("."),
buildDefinition: String = BuildDefinition.DEFAULT, buildDefinition: String = BuildDefinition.DEFAULT,
): BuildStatus { ): BuildStatus {
val build = buildExecutor.startBuild(branch, commit, workingDir, buildDefinition) val build = buildExecutor.startBuild(repo, branch, commit, buildDefinition)
var printed = 0L var printed = 0L
var result: BuildResult? = null var result: BuildResult? = null
while (result?.status?.isTerminal != true) { while (result?.status?.isTerminal != true) {
printed += printNewLogBytes(build.liveLogFile, printed) printed += printNewLogBytes(build.liveLogFile, printed)
result = repository.history().firstOrNull { it.artifactKey == build.artifactKey } result = repo.results.history().firstOrNull { it.artifactKey == build.artifactKey }
if (result?.status?.isTerminal != true) { if (result?.status?.isTerminal != true) {
Thread.sleep(pollIntervalMillis) Thread.sleep(pollIntervalMillis)
} }
} }
drainAfterBuild(build, printed) drainAfterBuild(repo, build, printed)
val after = result.duration?.let { " after ${UiFormats.duration(it)}" } ?: "" val after = result.duration?.let { " after ${UiFormats.duration(it)}" } ?: ""
println("build of branch $branch: ${result.status.name.lowercase()}$after") println("build of branch $branch: ${result.status.name.lowercase()}$after")
return result.status return result.status
@@ -64,6 +60,7 @@ class ConsoleBuildRunner(
* stored copy (which is byte-identical, so the offset carries over). * stored copy (which is byte-identical, so the offset carries over).
*/ */
private fun drainAfterBuild( private fun drainAfterBuild(
repo: RepoContext,
build: RunningBuild, build: RunningBuild,
alreadyPrinted: Long, alreadyPrinted: Long,
) { ) {
@@ -79,7 +76,7 @@ class ConsoleBuildRunner(
} }
Thread.sleep(pollIntervalMillis) Thread.sleep(pollIntervalMillis)
} }
artifactStore.artifactDir(build.artifactKey)?.let { artifactDir -> repo.artifactStore.artifactDir(build.artifactKey)?.let { artifactDir ->
printNewLogBytes(artifactDir.resolve(BuildExecutor.LIVE_LOG_FILE), printed) printNewLogBytes(artifactDir.resolve(BuildExecutor.LIVE_LOG_FILE), printed)
} }
} }
@@ -1,14 +1,13 @@
package de.hoennig.werkator.commands package de.hoennig.werkator.commands
import de.hoennig.werkator.build.BuildResult import de.hoennig.werkator.build.BuildResult
import de.hoennig.werkator.build.BuildResultRepository
import de.hoennig.werkator.build.BuildStatus import de.hoennig.werkator.build.BuildStatus
import de.hoennig.werkator.git.GitService import de.hoennig.werkator.git.GitService
import de.hoennig.werkator.repo.RepoContext
import org.springframework.stereotype.Component import org.springframework.stereotype.Component
import picocli.CommandLine.Command import picocli.CommandLine.Command
import picocli.CommandLine.ExitCode import picocli.CommandLine.ExitCode
import java.nio.file.Path import java.nio.file.Path
import java.nio.file.Paths
import java.util.concurrent.Callable import java.util.concurrent.Callable
/** /**
@@ -25,16 +24,18 @@ import java.util.concurrent.Callable
) )
class RetryCommand( class RetryCommand(
private val gitService: GitService, private val gitService: GitService,
private val repository: BuildResultRepository,
private val consoleBuildRunner: ConsoleBuildRunner, private val consoleBuildRunner: ConsoleBuildRunner,
/** The repository to retry in: the current working directory (a repo selector comes with the registry). */
var repo: RepoContext,
) : Callable<Int> { ) : Callable<Int> {
var workingDir: Path = Paths.get(".") private val workingDir: Path
get() = repo.workingDir
override fun call(): Int { override fun call(): Int {
val failed: List<BuildResult> val failed: List<BuildResult>
try { try {
fetchBestEffort() fetchBestEffort()
failed = repository.latestPerName().filter { it.status == BuildStatus.FAILED } failed = repo.results.latestPerName().filter { it.status == BuildStatus.FAILED }
} catch (e: Exception) { } catch (e: Exception) {
System.err.println("error: ${e.message}") System.err.println("error: ${e.message}")
return ExitCode.USAGE return ExitCode.USAGE
@@ -52,7 +53,7 @@ class RetryCommand(
} }
println("retrying build ${result.name} at commit ${commit.take(12)}") println("retrying build ${result.name} at commit ${commit.take(12)}")
// a failed build retries its recorded build definition (settings from the current config) // a failed build retries its recorded build definition (settings from the current config)
val status = consoleBuildRunner.buildAndStream(result.branch, commit, workingDir, result.build) val status = consoleBuildRunner.buildAndStream(repo, result.branch, commit, result.build)
if (status != BuildStatus.SUCCESS) { if (status != BuildStatus.SUCCESS) {
anyFailed = true anyFailed = true
} }
@@ -0,0 +1,16 @@
package de.hoennig.werkator.repo
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import java.nio.file.Paths
@Configuration
class RepoConfiguration {
/**
* The single-repository case: the current working directory, which is how every
* CLI command and the server resolve their files. Only paths are computed here, so
* the bean is safe outside a git repository.
*/
@Bean
fun currentRepo(repoContexts: RepoContexts): RepoContext = repoContexts.open(Paths.get("."))
}
@@ -0,0 +1,28 @@
package de.hoennig.werkator.repo
import de.hoennig.werkator.build.ArtifactStore
import de.hoennig.werkator.build.BuildResultRepository
import java.nio.file.Path
/**
* Everything Werkator needs to work on one repository (ADR 0009): its primary
* checkout, and the state that already lives inside or is keyed by it build
* results in `.git/werkator/`, the artifact store keyed by the repository path.
* Git access and config loading stay path-based services and take [workingDir].
*
* One instance exists per registered repository, and the instance itself is the
* identity: the executor serializes builds per (context, branch), so two contexts
* for the same directory would build it concurrently. Today there is exactly one,
* the current working directory ([RepoConfiguration]); the registry of the next
* session creates one per entry.
*/
class RepoContext(
/** Short unique name for display and, once routes carry it, the route segment; defaults to the directory basename. */
val name: String,
/** The primary checkout; never built in, its `.git/werkator/` holds the repository's state. */
val workingDir: Path,
val results: BuildResultRepository,
val artifactStore: ArtifactStore,
) {
override fun toString(): String = "RepoContext($name at $workingDir)"
}
@@ -0,0 +1,38 @@
package de.hoennig.werkator.repo
import de.hoennig.werkator.artifacts.FileArtifactStore
import de.hoennig.werkator.build.FileBuildResultRepository
import de.hoennig.werkator.config.ConfigLoader
import org.springframework.stereotype.Component
import java.nio.file.Path
/** Opens a [RepoContext] over a repository directory; nothing is touched until the first build. */
@Component
class RepoContexts(
private val configLoader: ConfigLoader,
) {
fun open(
workingDir: Path,
name: String = defaultName(workingDir),
): RepoContext =
RepoContext(
name = name,
workingDir = workingDir,
results = FileBuildResultRepository(workingDir.resolve(RESULTS_FILE)),
artifactStore = FileArtifactStore(configLoader, workingDir),
)
companion object {
/** Results file relative to the repository, next to the machine config in `.git/werkator/`. */
const val RESULTS_FILE = ".git/werkator/build-results.json"
/** The directory basename (ADR 0009); a filesystem root has none and falls back to a constant. */
fun defaultName(workingDir: Path): String =
workingDir
.toAbsolutePath()
.normalize()
.fileName
?.toString()
?: "repository"
}
}
@@ -1,10 +1,8 @@
package de.hoennig.werkator.server package de.hoennig.werkator.server
import de.hoennig.werkator.build.BuildResultRepository
import de.hoennig.werkator.git.GitService import de.hoennig.werkator.git.GitService
import de.hoennig.werkator.repo.RepoContext
import org.springframework.stereotype.Component import org.springframework.stereotype.Component
import java.nio.file.Path
import java.nio.file.Paths
/** /**
* The branches-view data, shared by the JSON API and the server-rendered page: * The branches-view data, shared by the JSON API and the server-rendered page:
@@ -18,10 +16,10 @@ import java.nio.file.Paths
@Component @Component
class BranchListing( class BranchListing(
private val gitService: GitService, private val gitService: GitService,
private val repository: BuildResultRepository,
) { ) {
fun branches(workingDir: Path = Paths.get(".")): List<BranchDto> { fun branches(repo: RepoContext): List<BranchDto> {
val heads = gitService.originBranchHeads(workingDir) val repository = repo.results
val heads = gitService.originBranchHeads(repo.workingDir)
val namedResults = repository.latestPerName().filter { it.name != it.branch && it.branch in heads } val namedResults = repository.latestPerName().filter { it.name != it.branch && it.branch in heads }
val branchesWithNamedPool = namedResults.map { it.branch }.toSet() val branchesWithNamedPool = namedResults.map { it.branch }.toSet()
val branchRows = val branchRows =
@@ -7,6 +7,7 @@ import de.hoennig.werkator.build.BuildResultRepository
import de.hoennig.werkator.build.BuildStatus import de.hoennig.werkator.build.BuildStatus
import de.hoennig.werkator.config.BuildDefinition import de.hoennig.werkator.config.BuildDefinition
import de.hoennig.werkator.git.GitService import de.hoennig.werkator.git.GitService
import de.hoennig.werkator.repo.RepoContext
import org.springframework.http.HttpStatus import org.springframework.http.HttpStatus
import org.springframework.http.ResponseEntity import org.springframework.http.ResponseEntity
import org.springframework.web.bind.annotation.DeleteMapping import org.springframework.web.bind.annotation.DeleteMapping
@@ -20,7 +21,6 @@ import java.nio.ByteBuffer
import java.nio.channels.FileChannel import java.nio.channels.FileChannel
import java.nio.file.Files import java.nio.file.Files
import java.nio.file.Path import java.nio.file.Path
import java.nio.file.Paths
import java.nio.file.StandardOpenOption import java.nio.file.StandardOpenOption
/** /**
@@ -38,15 +38,17 @@ class BuildsApiController(
private val controlTokens: ControlTokenService, private val controlTokens: ControlTokenService,
private val gitService: GitService, private val gitService: GitService,
private val branchListing: BranchListing, private val branchListing: BranchListing,
private val repo: RepoContext,
) { ) {
var workingDir: Path = Paths.get(".") private val workingDir: Path
get() = repo.workingDir
@GetMapping("/api/builds/latest") @GetMapping("/api/builds/latest")
fun latest(): List<BuildResultDto> = repository.latestPerName().map { BuildResultDto.from(it, it.isLatestGreen()) } fun latest(): List<BuildResultDto> = repository.latestPerName().map { BuildResultDto.from(it, it.isLatestGreen()) }
/** The legacy branches view: every origin branch with its latest build or `unknown`. */ /** The legacy branches view: every origin branch with its latest build or `unknown`. */
@GetMapping("/api/branches") @GetMapping("/api/branches")
fun branches(): List<BranchDto> = branchListing.branches(workingDir) fun branches(): List<BranchDto> = branchListing.branches(repo)
@GetMapping("/api/builds/history") @GetMapping("/api/builds/history")
fun history(): List<BuildResultDto> = repository.history().map { BuildResultDto.from(it, it.isLatestGreen()) } fun history(): List<BuildResultDto> = repository.history().map { BuildResultDto.from(it, it.isLatestGreen()) }
@@ -122,6 +124,7 @@ class BuildsApiController(
// a restarted build re-runs its recorded build definition (settings from the current config) // a restarted build re-runs its recorded build definition (settings from the current config)
val running = val running =
buildExecutor.startBuild( buildExecutor.startBuild(
repo = repo,
branch = branchName, branch = branchName,
commit = commit, commit = commit,
build = latest?.build ?: BuildDefinition.DEFAULT, build = latest?.build ?: BuildDefinition.DEFAULT,
@@ -1,5 +1,6 @@
package de.hoennig.werkator.server package de.hoennig.werkator.server
import de.hoennig.werkator.repo.RepoContext
import de.hoennig.werkator.watcher.Watcher import de.hoennig.werkator.watcher.Watcher
import jakarta.annotation.PreDestroy import jakarta.annotation.PreDestroy
import org.springframework.boot.context.event.ApplicationReadyEvent import org.springframework.boot.context.event.ApplicationReadyEvent
@@ -8,18 +9,19 @@ import org.springframework.context.event.EventListener
import org.springframework.stereotype.Component import org.springframework.stereotype.Component
/** /**
* Starts the watcher poll loop once the server context is ready and stops it on * Starts the watcher poll loop over the served repository once the server context
* shutdown. Only in the `server` profile CLI commands and tests never start * is ready and stops it on shutdown. Only in the `server` profile CLI commands
* the loop (see [Watcher]). * and tests never start the loop (see [Watcher]).
*/ */
@Component @Component
@Profile("server") @Profile("server")
class ServerWatcherLifecycle( class ServerWatcherLifecycle(
private val watcher: Watcher, private val watcher: Watcher,
private val repo: RepoContext,
) { ) {
@EventListener(ApplicationReadyEvent::class) @EventListener(ApplicationReadyEvent::class)
fun onApplicationReady() { fun onApplicationReady() {
watcher.start() watcher.start(repo)
} }
@PreDestroy @PreDestroy
@@ -9,6 +9,7 @@ import de.hoennig.werkator.config.ConfigFiles
import de.hoennig.werkator.config.ConfigLoader import de.hoennig.werkator.config.ConfigLoader
import de.hoennig.werkator.git.GitService import de.hoennig.werkator.git.GitService
import de.hoennig.werkator.metrics.SystemMetricsCollector import de.hoennig.werkator.metrics.SystemMetricsCollector
import de.hoennig.werkator.repo.RepoContext
import jakarta.servlet.http.HttpServletRequest import jakarta.servlet.http.HttpServletRequest
import org.springframework.beans.factory.ObjectProvider import org.springframework.beans.factory.ObjectProvider
import org.springframework.boot.info.BuildProperties import org.springframework.boot.info.BuildProperties
@@ -22,7 +23,6 @@ import org.springframework.web.servlet.view.RedirectView
import java.nio.charset.StandardCharsets import java.nio.charset.StandardCharsets
import java.nio.file.Files import java.nio.file.Files
import java.nio.file.Path import java.nio.file.Path
import java.nio.file.Paths
import kotlin.io.path.name import kotlin.io.path.name
import kotlin.streams.asSequence import kotlin.streams.asSequence
@@ -43,8 +43,10 @@ class UiController(
private val branchListing: BranchListing, private val branchListing: BranchListing,
private val branchPermalinks: BranchPermalinks, private val branchPermalinks: BranchPermalinks,
private val buildProperties: ObjectProvider<BuildProperties>, private val buildProperties: ObjectProvider<BuildProperties>,
private val repo: RepoContext,
) { ) {
var workingDir: Path = Paths.get(".") private val workingDir: Path
get() = repo.workingDir
/** /**
* Permanent redirects for the legacy script's static page names, so bookmarks * Permanent redirects for the legacy script's static page names, so bookmarks
@@ -71,7 +73,7 @@ class UiController(
@GetMapping("/branches") @GetMapping("/branches")
fun branches(model: Model): String { fun branches(model: Model): String {
val links = baseModel(model, view = "branches", pageTitle = "Branches") val links = baseModel(model, view = "branches", pageTitle = "Branches")
model.addAttribute("rows", branchListing.branches(workingDir).map { BuildRowView.from(it, links) }) model.addAttribute("rows", branchListing.branches(repo).map { BuildRowView.from(it, links) })
model.addAttribute("apiPath", "/api/branches") model.addAttribute("apiPath", "/api/branches")
model.addAttribute("allowRestart", true) model.addAttribute("allowRestart", true)
// a row here stands for a branch, not for a past run // a row here stands for a branch, not for a past run
@@ -1,9 +1,7 @@
package de.hoennig.werkator.watcher package de.hoennig.werkator.watcher
import de.hoennig.werkator.build.ArtifactKeys import de.hoennig.werkator.build.ArtifactKeys
import de.hoennig.werkator.build.ArtifactStore
import de.hoennig.werkator.build.BuildExecutor import de.hoennig.werkator.build.BuildExecutor
import de.hoennig.werkator.build.BuildResultRepository
import de.hoennig.werkator.build.BuildStatus import de.hoennig.werkator.build.BuildStatus
import de.hoennig.werkator.build.GitWorktreeWorkspaces import de.hoennig.werkator.build.GitWorktreeWorkspaces
import de.hoennig.werkator.config.BuildDefinition import de.hoennig.werkator.config.BuildDefinition
@@ -12,11 +10,11 @@ import de.hoennig.werkator.config.ConfigLoader
import de.hoennig.werkator.config.DurationParser import de.hoennig.werkator.config.DurationParser
import de.hoennig.werkator.config.WerkatorConfig import de.hoennig.werkator.config.WerkatorConfig
import de.hoennig.werkator.git.GitService import de.hoennig.werkator.git.GitService
import de.hoennig.werkator.repo.RepoContext
import org.slf4j.LoggerFactory import org.slf4j.LoggerFactory
import org.springframework.stereotype.Service import org.springframework.stereotype.Service
import java.nio.file.Files import java.nio.file.Files
import java.nio.file.Path import java.nio.file.Path
import java.nio.file.Paths
import java.time.Clock import java.time.Clock
import java.time.Instant import java.time.Instant
import java.time.LocalDate import java.time.LocalDate
@@ -39,8 +37,6 @@ import java.util.concurrent.TimeUnit
class Watcher( class Watcher(
private val gitService: GitService, private val gitService: GitService,
private val buildExecutor: BuildExecutor, private val buildExecutor: BuildExecutor,
private val repository: BuildResultRepository,
private val artifactStore: ArtifactStore,
private val configLoader: ConfigLoader, private val configLoader: ConfigLoader,
private val clock: Clock, private val clock: Clock,
) { ) {
@@ -51,9 +47,22 @@ class Watcher(
@Volatile @Volatile
private var state = WatcherState() private var state = WatcherState()
/** The branches.*.autoBuild deprecation is logged once per watcher instance, not once per poll. */ /** What the watcher remembers about a repository between polls, keyed by the context (identity). */
private val watched = ConcurrentHashMap<RepoContext, RepoWatch>()
fun state(): WatcherState = state
private fun watchOf(repo: RepoContext): RepoWatch = watched.computeIfAbsent(repo) { RepoWatch() }
/**
* The per-repository poll memory: what was logged already, and the cached branch
* definitions. Kept apart from the shared [WatcherState] so that the next session
* can iterate contexts without one repository's outage silencing another's.
*/
private class RepoWatch {
/** The branches.*.autoBuild deprecation is logged once per repository, not once per poll. */
@Volatile @Volatile
private var warnedDeprecatedAutoBuild = false var warnedDeprecatedAutoBuild = false
/** /**
* The fetch failure last written to the log, so a lasting outage does not repeat the * The fetch failure last written to the log, so a lasting outage does not repeat the
@@ -62,28 +71,27 @@ class Watcher(
* loggable. * loggable.
*/ */
@Volatile @Volatile
private var loggedFetchError: String? = null var loggedFetchError: String? = null
/** Build definitions per branch, cached by the branch's head commit — see [definitionsFor]. */ /** Build definitions per branch, cached by the branch's head commit — see [definitionsFor]. */
private val branchDefinitions = ConcurrentHashMap<String, CachedDefinitions>() val branchDefinitions = ConcurrentHashMap<String, CachedDefinitions>()
}
fun state(): WatcherState = state
/** /**
* Runs the startup recovery and schedules the poll loop with the fixed delay * Runs the startup recovery and schedules the poll loop with the fixed delay
* `watcher.pollInterval`; the first poll runs immediately. * `watcher.pollInterval`; the first poll runs immediately.
*/ */
@Synchronized @Synchronized
fun start(workingDir: Path = Paths.get(".")) { fun start(repo: RepoContext) {
check(scheduler == null) { "watcher is already running" } check(scheduler == null) { "watcher is already running" }
recoverOnStartup(workingDir) recoverOnStartup(repo)
val interval = DurationParser.parse(configLoader.load(workingDir).watcher.pollInterval) val interval = DurationParser.parse(configLoader.load(repo.workingDir).watcher.pollInterval)
scheduler = scheduler =
Executors Executors
.newSingleThreadScheduledExecutor { runnable -> .newSingleThreadScheduledExecutor { runnable ->
Thread(runnable, "werkator-watcher").apply { isDaemon = true } Thread(runnable, "werkator-watcher").apply { isDaemon = true }
}.also { }.also {
it.scheduleWithFixedDelay({ pollSafely(workingDir) }, 0, interval.toMillis(), TimeUnit.MILLISECONDS) it.scheduleWithFixedDelay({ pollSafely(repo) }, 0, interval.toMillis(), TimeUnit.MILLISECONDS)
} }
state = state.copy(running = true) state = state.copy(running = true)
} }
@@ -100,7 +108,9 @@ class Watcher(
* superseded PENDING builds as INTERRUPTED, then re-enqueue every branch whose * superseded PENDING builds as INTERRUPTED, then re-enqueue every branch whose
* latest build never finished and which still exists on origin. * latest build never finished and which still exists on origin.
*/ */
fun recoverOnStartup(workingDir: Path = Paths.get(".")) { fun recoverOnStartup(repo: RepoContext) {
val workingDir = repo.workingDir
val repository = repo.results
try { try {
gitService.fetchOrigin(workingDir) gitService.fetchOrigin(workingDir)
} catch (e: Exception) { } catch (e: Exception) {
@@ -130,7 +140,7 @@ class Watcher(
} }
log.info("restarting unfinished build {} of branch {}", result.build, result.branch) log.info("restarting unfinished build {} of branch {}", result.build, result.branch)
// the re-run resolves its settings from the current config by the recorded build name // the re-run resolves its settings from the current config by the recorded build name
buildExecutor.startBuild(result.branch, commit, workingDir, result.build) buildExecutor.startBuild(repo, result.branch, commit, result.build)
} }
} }
@@ -141,46 +151,48 @@ class Watcher(
* fast-forward the local branch refs, and finally prune results, artifacts, and * fast-forward the local branch refs, and finally prune results, artifacts, and
* worktrees of branches gone from origin. * worktrees of branches gone from origin.
*/ */
fun poll(workingDir: Path = Paths.get(".")) { fun poll(repo: RepoContext) {
val startedAt = clock.instant() val startedAt = clock.instant()
val workingDir = repo.workingDir
val watch = watchOf(repo)
try { try {
gitService.fetchOrigin(workingDir) gitService.fetchOrigin(workingDir)
if (loggedFetchError != null) { if (watch.loggedFetchError != null) {
log.info("fetching origin succeeded again") log.info("fetching origin succeeded again")
loggedFetchError = null watch.loggedFetchError = null
} }
} catch (e: Exception) { } catch (e: Exception) {
val failure = e.message ?: e.javaClass.simpleName val failure = e.message ?: e.javaClass.simpleName
if (loggedFetchError != failure) { if (watch.loggedFetchError != failure) {
log.warn("fetching origin failed; retrying every cycle until it succeeds: {}", failure) log.warn("fetching origin failed; retrying every cycle until it succeeds: {}", failure)
loggedFetchError = failure watch.loggedFetchError = failure
} }
state = state.copy(lastPollAt = startedAt, lastFetchError = failure) state = state.copy(lastPollAt = startedAt, lastFetchError = failure)
return return
} }
val config = configLoader.load(workingDir) val config = configLoader.load(workingDir)
val originBranches = gitService.originBranches(workingDir) val originBranches = gitService.originBranches(workingDir)
enqueueDueBranches(config, originBranches.toSet(), workingDir) enqueueDueBranches(repo, config, originBranches.toSet())
if (config.watcher.fastForwardLocalRefs) { if (config.watcher.fastForwardLocalRefs) {
fastForwardLocalRefs(workingDir) fastForwardLocalRefs(workingDir)
} }
prune(config, originBranches, workingDir) prune(repo, config, originBranches)
state = state =
state.copy( state.copy(
lastPollAt = startedAt, lastPollAt = startedAt,
lastFetchError = null, lastFetchError = null,
lastPollError = null, lastPollError = null,
queuedBranches = queuedBranches =
repository repo.results
.latestPerName() .latestPerName()
.filter { it.status == BuildStatus.PENDING || it.status == BuildStatus.RUNNING } .filter { it.status == BuildStatus.PENDING || it.status == BuildStatus.RUNNING }
.map { it.name }, .map { it.name },
) )
} }
private fun pollSafely(workingDir: Path) { private fun pollSafely(repo: RepoContext) {
try { try {
poll(workingDir) poll(repo)
} catch (e: Exception) { } catch (e: Exception) {
log.error("poll cycle failed", e) log.error("poll cycle failed", e)
state = state.copy(lastPollAt = clock.instant(), lastPollError = e.message ?: e.javaClass.simpleName) state = state.copy(lastPollAt = clock.instant(), lastPollError = e.message ?: e.javaClass.simpleName)
@@ -208,16 +220,17 @@ class Watcher(
} }
private fun enqueueDueBranches( private fun enqueueDueBranches(
repo: RepoContext,
config: WerkatorConfig, config: WerkatorConfig,
originBranches: Set<String>, originBranches: Set<String>,
workingDir: Path,
) { ) {
val workingDir = repo.workingDir
// one ls-remote per poll cycle at most, and only when a due branch requires a pull request // one ls-remote per poll cycle at most, and only when a due branch requires a pull request
val pullRequestHeads = lazy { gitService.pullRequestHeads(workingDir) } val pullRequestHeads = lazy { gitService.pullRequestHeads(workingDir) }
// one for-each-ref per cycle at most, and only when a definition filters by activeWithin // one for-each-ref per cycle at most, and only when a definition filters by activeWithin
val headCommitTimes = lazy { gitService.originBranchCommitTimes(workingDir) } val headCommitTimes = lazy { gitService.originBranchCommitTimes(workingDir) }
val heads = gitService.originBranchHeads(workingDir) val heads = gitService.originBranchHeads(workingDir)
branchDefinitions.keys.retainAll(originBranches) watchOf(repo).branchDefinitions.keys.retainAll(originBranches)
val changedLocal = val changedLocal =
gitService gitService
.localBranches(workingDir) .localBranches(workingDir)
@@ -226,15 +239,15 @@ class Watcher(
gitService.newOriginBranches(DurationParser.parse(config.watcher.newBranchMaxAge), workingDir) gitService.newOriginBranches(DurationParser.parse(config.watcher.newBranchMaxAge), workingDir)
val changed = (changedLocal + newOrigin).distinct() val changed = (changedLocal + newOrigin).distinct()
for (branch in changed) { for (branch in changed) {
val onPush = definitionsFor(branch, heads[branch], workingDir, config).filterValues { it.trigger.onPush } val onPush = definitionsFor(repo, branch, heads[branch], config).filterValues { it.trigger.onPush }
for ((buildName, definition) in onPush) { for ((buildName, definition) in onPush) {
if (selects(definition, branch, headCommitTimes)) { if (selects(definition, branch, headCommitTimes)) {
startBuildIfDue(branch, allowSameCommit = false, config, pullRequestHeads, workingDir, buildName) startBuildIfDue(repo, branch, allowSameCommit = false, config, pullRequestHeads, buildName)
} }
} }
} }
enqueueScheduledBuilds(config, originBranches, heads, pullRequestHeads, headCommitTimes, workingDir) enqueueScheduledBuilds(repo, config, originBranches, heads, pullRequestHeads, headCommitTimes)
enqueueDeprecatedAutoBuilds(config, originBranches, pullRequestHeads, workingDir) enqueueDeprecatedAutoBuilds(repo, config, originBranches, pullRequestHeads)
} }
/** /**
@@ -252,11 +265,13 @@ class Watcher(
* instead of failing the poll cycle. * instead of failing the poll cycle.
*/ */
private fun definitionsFor( private fun definitionsFor(
repo: RepoContext,
branch: String, branch: String,
headCommit: String?, headCommit: String?,
workingDir: Path,
primary: WerkatorConfig, primary: WerkatorConfig,
): Map<String, BuildDefinition> { ): Map<String, BuildDefinition> {
val workingDir = repo.workingDir
val branchDefinitions = watchOf(repo).branchDefinitions
val commit = headCommit ?: return primary.effectiveBuildDefinitions() val commit = headCommit ?: return primary.effectiveBuildDefinitions()
branchDefinitions[branch]?.takeIf { it.commit == commit && it.primary == primary }?.let { return it.definitions } branchDefinitions[branch]?.takeIf { it.commit == commit && it.primary == primary }?.let { return it.definitions }
val definitions = val definitions =
@@ -305,14 +320,15 @@ class Watcher(
* without pull-request refs. * without pull-request refs.
*/ */
private fun startBuildIfDue( private fun startBuildIfDue(
repo: RepoContext,
branch: String, branch: String,
allowSameCommit: Boolean, allowSameCommit: Boolean,
config: WerkatorConfig, config: WerkatorConfig,
pullRequestHeads: Lazy<Set<String>>, pullRequestHeads: Lazy<Set<String>>,
workingDir: Path,
build: String = BuildDefinition.DEFAULT, build: String = BuildDefinition.DEFAULT,
): Boolean { ): Boolean {
val latest = repository.latestFor(BuildDefinition.poolName(branch, build)) val workingDir = repo.workingDir
val latest = repo.results.latestFor(BuildDefinition.poolName(branch, build))
if (latest?.status == BuildStatus.PENDING || latest?.status == BuildStatus.RUNNING) { if (latest?.status == BuildStatus.PENDING || latest?.status == BuildStatus.RUNNING) {
return false return false
} }
@@ -328,7 +344,7 @@ class Watcher(
return false return false
} }
log.info("enqueueing build {} of branch {} at commit {}", build, branch, commit) log.info("enqueueing build {} of branch {} at commit {}", build, branch, commit)
buildExecutor.startBuild(branch, commit, workingDir, build) buildExecutor.startBuild(repo, branch, commit, build)
return true return true
} }
@@ -338,20 +354,20 @@ class Watcher(
* the point of a scheduled build. * the point of a scheduled build.
*/ */
private fun enqueueScheduledBuilds( private fun enqueueScheduledBuilds(
repo: RepoContext,
config: WerkatorConfig, config: WerkatorConfig,
originBranches: Set<String>, originBranches: Set<String>,
heads: Map<String, String>, heads: Map<String, String>,
pullRequestHeads: Lazy<Set<String>>, pullRequestHeads: Lazy<Set<String>>,
headCommitTimes: Lazy<Map<String, Instant>>, headCommitTimes: Lazy<Map<String, Instant>>,
workingDir: Path,
) { ) {
val autoBuildState = lazy { FileAutoBuildState(workingDir.resolve(AUTO_BUILDS_FILE)) } val autoBuildState = lazy { FileAutoBuildState(repo.workingDir.resolve(AUTO_BUILDS_FILE)) }
val now = clock.instant() val now = clock.instant()
val today = LocalDate.ofInstant(now, ZoneOffset.UTC) val today = LocalDate.ofInstant(now, ZoneOffset.UTC)
val timeOfDay = LocalTime.ofInstant(now, ZoneOffset.UTC) val timeOfDay = LocalTime.ofInstant(now, ZoneOffset.UTC)
for (branch in originBranches) { for (branch in originBranches) {
val scheduled = val scheduled =
definitionsFor(branch, heads[branch], workingDir, config).filterValues { definitionsFor(repo, branch, heads[branch], config).filterValues {
it.trigger.atTimes.isNotEmpty() it.trigger.atTimes.isNotEmpty()
} }
for ((buildName, definition) in scheduled) { for ((buildName, definition) in scheduled) {
@@ -363,7 +379,7 @@ class Watcher(
if (autoBuildState.value.isTriggered(pool, today, slot)) { if (autoBuildState.value.isTriggered(pool, today, slot)) {
continue continue
} }
if (startBuildIfDue(branch, allowSameCommit = true, config, pullRequestHeads, workingDir, buildName)) { if (startBuildIfDue(repo, branch, allowSameCommit = true, config, pullRequestHeads, buildName)) {
autoBuildState.value.markTriggered(pool, today, slot) autoBuildState.value.markTriggered(pool, today, slot)
} }
} }
@@ -376,10 +392,10 @@ class Watcher(
* `builds` entry with `atTimes` and a single-branch selector would do. * `builds` entry with `atTimes` and a single-branch selector would do.
*/ */
private fun enqueueDeprecatedAutoBuilds( private fun enqueueDeprecatedAutoBuilds(
repo: RepoContext,
config: WerkatorConfig, config: WerkatorConfig,
originBranches: Set<String>, originBranches: Set<String>,
pullRequestHeads: Lazy<Set<String>>, pullRequestHeads: Lazy<Set<String>>,
workingDir: Path,
) { ) {
val autoBuildBranches = val autoBuildBranches =
config.branches.filter { (branch, branchConfig) -> config.branches.filter { (branch, branchConfig) ->
@@ -388,14 +404,15 @@ class Watcher(
if (autoBuildBranches.isEmpty()) { if (autoBuildBranches.isEmpty()) {
return return
} }
if (!warnedDeprecatedAutoBuild) { val watch = watchOf(repo)
warnedDeprecatedAutoBuild = true if (!watch.warnedDeprecatedAutoBuild) {
watch.warnedDeprecatedAutoBuild = true
log.warn( log.warn(
"branches.*.autoBuild is deprecated; define a build with atTimes in the builds section instead (branches: {})", "branches.*.autoBuild is deprecated; define a build with atTimes in the builds section instead (branches: {})",
autoBuildBranches.keys.joinToString(", "), autoBuildBranches.keys.joinToString(", "),
) )
} }
val autoBuildState = FileAutoBuildState(workingDir.resolve(AUTO_BUILDS_FILE)) val autoBuildState = FileAutoBuildState(repo.workingDir.resolve(AUTO_BUILDS_FILE))
val now = clock.instant() val now = clock.instant()
val today = LocalDate.ofInstant(now, ZoneOffset.UTC) val today = LocalDate.ofInstant(now, ZoneOffset.UTC)
val timeOfDay = LocalTime.ofInstant(now, ZoneOffset.UTC) val timeOfDay = LocalTime.ofInstant(now, ZoneOffset.UTC)
@@ -408,7 +425,7 @@ class Watcher(
log.warn("skipping auto build of branch {}: branch is not on origin", branch) log.warn("skipping auto build of branch {}: branch is not on origin", branch)
continue continue
} }
if (startBuildIfDue(branch, allowSameCommit = true, config, pullRequestHeads, workingDir)) { if (startBuildIfDue(repo, branch, allowSameCommit = true, config, pullRequestHeads)) {
autoBuildState.markTriggered(branch, today, slot) autoBuildState.markTriggered(branch, today, slot)
} }
} }
@@ -416,28 +433,29 @@ class Watcher(
/** Results first, then artifacts of dropped results, then worktrees of branches gone from origin. */ /** Results first, then artifacts of dropped results, then worktrees of branches gone from origin. */
private fun prune( private fun prune(
repo: RepoContext,
config: WerkatorConfig, config: WerkatorConfig,
originBranches: List<String>, originBranches: List<String>,
workingDir: Path,
) { ) {
val retentionCutoff = val retentionCutoff =
config.artifacts.retentionMaxAge config.artifacts.retentionMaxAge
.takeIf { it.isNotBlank() } .takeIf { it.isNotBlank() }
?.let { clock.instant().minus(DurationParser.parse(it)) } ?.let { clock.instant().minus(DurationParser.parse(it)) }
repository.prune( repo.results.prune(
originBranches, originBranches,
config.artifacts.retentionPerBranch, config.artifacts.retentionPerBranch,
config.artifacts.keepLatestGreen, config.artifacts.keepLatestGreen,
retentionCutoff, retentionCutoff,
) )
artifactStore.prune(repository.history()) repo.artifactStore.prune(repo.results.history())
pruneWorktrees(originBranches, workingDir) pruneWorktrees(repo, originBranches)
} }
private fun pruneWorktrees( private fun pruneWorktrees(
repo: RepoContext,
originBranches: List<String>, originBranches: List<String>,
workingDir: Path,
) { ) {
val workingDir = repo.workingDir
val worktreesDir = workingDir.resolve(GitWorktreeWorkspaces.WORKTREES_DIR) val worktreesDir = workingDir.resolve(GitWorktreeWorkspaces.WORKTREES_DIR)
if (!Files.isDirectory(worktreesDir)) { if (!Files.isDirectory(worktreesDir)) {
return return
@@ -445,7 +463,7 @@ class Watcher(
val keep = originBranches.map { ArtifactKeys.branchKey(it) }.toMutableSet() val keep = originBranches.map { ArtifactKeys.branchKey(it) }.toMutableSet()
// never delete under a build that is still queued or executing // never delete under a build that is still queued or executing
buildExecutor.currentBuilds().forEach { keep += ArtifactKeys.branchKey(it.branch) } buildExecutor.currentBuilds().forEach { keep += ArtifactKeys.branchKey(it.branch) }
repository repo.results
.latestPerName() .latestPerName()
.filter { it.status == BuildStatus.PENDING || it.status == BuildStatus.RUNNING } .filter { it.status == BuildStatus.PENDING || it.status == BuildStatus.RUNNING }
.forEach { keep += ArtifactKeys.branchKey(it.branch) } .forEach { keep += ArtifactKeys.branchKey(it.branch) }
@@ -6,6 +6,7 @@ import de.hoennig.werkator.build.FileBuildResultRepository
import de.hoennig.werkator.build.ProcessBuildRunner import de.hoennig.werkator.build.ProcessBuildRunner
import de.hoennig.werkator.config.ConfigLoader import de.hoennig.werkator.config.ConfigLoader
import de.hoennig.werkator.gitea.GiteaClient import de.hoennig.werkator.gitea.GiteaClient
import de.hoennig.werkator.repo.RepoContext
import io.kotest.assertions.nondeterministic.eventually import io.kotest.assertions.nondeterministic.eventually
import io.kotest.core.spec.style.FunSpec import io.kotest.core.spec.style.FunSpec
import io.kotest.matchers.nulls.shouldNotBeNull import io.kotest.matchers.nulls.shouldNotBeNull
@@ -37,18 +38,17 @@ class BuildExecutorArtifactIntegrationTest : FunSpec() {
) )
val workspace = Files.createDirectories(workingDir.resolve("workspace")) val workspace = Files.createDirectories(workingDir.resolve("workspace"))
val store = FileArtifactStore(ConfigLoader(), workingDir) val store = FileArtifactStore(ConfigLoader(), workingDir)
val repo = RepoContext("test", workingDir, FileBuildResultRepository(workingDir.resolve("build-results.json")), store)
val executor = val executor =
BuildExecutor( BuildExecutor(
repository = FileBuildResultRepository(workingDir.resolve("build-results.json")),
configLoader = ConfigLoader(), configLoader = ConfigLoader(),
giteaClient = mockk<GiteaClient>(relaxed = true), giteaClient = mockk<GiteaClient>(relaxed = true),
buildRunner = ProcessBuildRunner(), buildRunner = ProcessBuildRunner(),
workspaces = BranchWorkspaces { _, _, _ -> workspace }, workspaces = BranchWorkspaces { _, _, _ -> workspace },
artifactStore = store,
eventPublisher = ApplicationEventPublisher { }, eventPublisher = ApplicationEventPublisher { },
) )
val build = executor.startBuild("main", "abc123", workingDir) val build = executor.startBuild(repo, "main", "abc123")
lateinit var artifactDir: java.nio.file.Path lateinit var artifactDir: java.nio.file.Path
eventually(30.seconds) { eventually(30.seconds) {
@@ -2,6 +2,7 @@ package de.hoennig.werkator.build
import de.hoennig.werkator.config.ConfigLoader import de.hoennig.werkator.config.ConfigLoader
import de.hoennig.werkator.gitea.GiteaClient import de.hoennig.werkator.gitea.GiteaClient
import de.hoennig.werkator.repo.RepoContext
import io.kotest.assertions.nondeterministic.eventually import io.kotest.assertions.nondeterministic.eventually
import io.kotest.core.spec.style.FunSpec import io.kotest.core.spec.style.FunSpec
import io.kotest.matchers.booleans.shouldBeFalse import io.kotest.matchers.booleans.shouldBeFalse
@@ -48,14 +49,13 @@ class BuildExecutorTest : FunSpec() {
Files.createDirectories(workingDir.resolve(workspaceSubdir)) Files.createDirectories(workingDir.resolve(workspaceSubdir))
} }
} }
val repo = RepoContext("test", workingDir, repository, artifactStore)
val executor = val executor =
BuildExecutor( BuildExecutor(
repository = repository,
configLoader = ConfigLoader(), configLoader = ConfigLoader(),
giteaClient = giteaClient, giteaClient = giteaClient,
buildRunner = buildRunner, buildRunner = buildRunner,
workspaces = workspaces, workspaces = workspaces,
artifactStore = artifactStore,
eventPublisher = eventPublisher =
ApplicationEventPublisher { event -> ApplicationEventPublisher { event ->
if (event is BuildStatusChangedEvent) { if (event is BuildStatusChangedEvent) {
@@ -112,7 +112,7 @@ class BuildExecutorTest : FunSpec() {
cleanCommand = "echo clean-\$branch", cleanCommand = "echo clean-\$branch",
) )
val build = h.executor.startBuild("main", "abc123", h.workingDir) val build = h.executor.startBuild(h.repo, "main", "abc123")
awaitStatus(h, "main", BuildStatus.SUCCESS) awaitStatus(h, "main", BuildStatus.SUCCESS)
awaitIdle(h) awaitIdle(h)
@@ -141,7 +141,7 @@ class BuildExecutorTest : FunSpec() {
test("build commands run in the workspace prepared for the branch") { test("build commands run in the workspace prepared for the branch") {
val h = harness(buildCommand = "pwd", workspaceSubdir = "branch-workspace") val h = harness(buildCommand = "pwd", workspaceSubdir = "branch-workspace")
val build = h.executor.startBuild("main", "abc123", h.workingDir) val build = h.executor.startBuild(h.repo, "main", "abc123")
awaitStatus(h, "main", BuildStatus.SUCCESS) awaitStatus(h, "main", BuildStatus.SUCCESS)
awaitIdle(h) awaitIdle(h)
@@ -151,7 +151,7 @@ class BuildExecutorTest : FunSpec() {
test("the repository reports RUNNING while the build sleeps") { test("the repository reports RUNNING while the build sleeps") {
val h = harness("sleep 10") val h = harness("sleep 10")
val build = h.executor.startBuild("main", "abc123", h.workingDir) val build = h.executor.startBuild(h.repo, "main", "abc123")
eventually(10.seconds) { eventually(10.seconds) {
h.repository.latestFor("main")?.status shouldBe BuildStatus.RUNNING h.repository.latestFor("main")?.status shouldBe BuildStatus.RUNNING
@@ -166,8 +166,8 @@ class BuildExecutorTest : FunSpec() {
// the first build sleeps, the second (queued behind it) finishes instantly // the first build sleeps, the second (queued behind it) finishes instantly
val h = harness("test -f slow-done || { touch slow-done; sleep 2; }") val h = harness("test -f slow-done || { touch slow-done; sleep 2; }")
h.executor.startBuild("main", "abc123", h.workingDir) h.executor.startBuild(h.repo, "main", "abc123")
val second = h.executor.startBuild("main", "abc124", h.workingDir) val second = h.executor.startBuild(h.repo, "main", "abc124")
eventually(30.seconds) { eventually(30.seconds) {
h.repository h.repository
@@ -207,7 +207,7 @@ class BuildExecutorTest : FunSpec() {
} }
val h = harness("unused", buildRunner = auxRunner) val h = harness("unused", buildRunner = auxRunner)
val build = h.executor.startBuild("main", "abc123", h.workingDir) val build = h.executor.startBuild(h.repo, "main", "abc123")
eventually(10.seconds) { eventually(10.seconds) {
h.repository.latestFor("main")?.status shouldBe BuildStatus.RUNNING h.repository.latestFor("main")?.status shouldBe BuildStatus.RUNNING
} }
@@ -232,7 +232,7 @@ class BuildExecutorTest : FunSpec() {
""".trimIndent(), """.trimIndent(),
) )
val nightly = h.executor.startBuild("main", "sha-1", h.workingDir, "pitest") val nightly = h.executor.startBuild(h.repo, "main", "sha-1", "pitest")
awaitStatus(h, "main@pitest", BuildStatus.SUCCESS) awaitStatus(h, "main@pitest", BuildStatus.SUCCESS)
awaitIdle(h) awaitIdle(h)
@@ -250,7 +250,7 @@ class BuildExecutorTest : FunSpec() {
h.repository.latestFor("main") shouldBe null h.repository.latestFor("main") shouldBe null
// the same branch under the default build runs the regular command // the same branch under the default build runs the regular command
val regular = h.executor.startBuild("main", "sha-2", h.workingDir) val regular = h.executor.startBuild(h.repo, "main", "sha-2")
awaitStatus(h, "main", BuildStatus.SUCCESS) awaitStatus(h, "main", BuildStatus.SUCCESS)
awaitIdle(h) awaitIdle(h)
Files.readString(regular.stagingDir.resolve("build.stdout.log")) shouldContain "regular-main" Files.readString(regular.stagingDir.resolve("build.stdout.log")) shouldContain "regular-main"
@@ -263,7 +263,7 @@ class BuildExecutorTest : FunSpec() {
test("a build whose definition was removed from the config falls back to the branch's settings") { test("a build whose definition was removed from the config falls back to the branch's settings") {
val h = harness(buildCommand = "echo regular-\$branch") val h = harness(buildCommand = "echo regular-\$branch")
val build = h.executor.startBuild("main", "sha-1", h.workingDir, "gone-build") val build = h.executor.startBuild(h.repo, "main", "sha-1", "gone-build")
awaitStatus(h, "main@gone-build", BuildStatus.SUCCESS) awaitStatus(h, "main@gone-build", BuildStatus.SUCCESS)
awaitIdle(h) awaitIdle(h)
@@ -273,23 +273,23 @@ class BuildExecutorTest : FunSpec() {
test("startBuild returns the active build of the same branch and commit instead of stacking a duplicate") { test("startBuild returns the active build of the same branch and commit instead of stacking a duplicate") {
val h = harness("sleep 30") val h = harness("sleep 30")
val first = h.executor.startBuild("main", "abc123", h.workingDir) val first = h.executor.startBuild(h.repo, "main", "abc123")
// a double-triggered UI restart: same branch, same commit, while queued or running // a double-triggered UI restart: same branch, same commit, while queued or running
val duplicate = h.executor.startBuild("main", "abc123", h.workingDir) val duplicate = h.executor.startBuild(h.repo, "main", "abc123")
duplicate.artifactKey shouldBe first.artifactKey duplicate.artifactKey shouldBe first.artifactKey
h.repository.history().map { it.artifactKey } shouldContainExactly listOf(first.artifactKey) h.repository.history().map { it.artifactKey } shouldContainExactly listOf(first.artifactKey)
// another build definition of the same commit is its own pool — not a duplicate // another build definition of the same commit is its own pool — not a duplicate
val nightly = h.executor.startBuild("main", "abc123", h.workingDir, "pitest") val nightly = h.executor.startBuild(h.repo, "main", "abc123", "pitest")
nightly.artifactKey shouldNotBe first.artifactKey nightly.artifactKey shouldNotBe first.artifactKey
// another commit of the branch is a distinct build, queued behind the first // another commit of the branch is a distinct build, queued behind the first
val newerCommit = h.executor.startBuild("main", "abc124", h.workingDir) val newerCommit = h.executor.startBuild(h.repo, "main", "abc124")
newerCommit.artifactKey shouldNotBe first.artifactKey newerCommit.artifactKey shouldNotBe first.artifactKey
// a cancel-requested build no longer blocks re-queueing its commit // a cancel-requested build no longer blocks re-queueing its commit
h.executor.cancel(first.artifactKey).shouldBeTrue() h.executor.cancel(first.artifactKey).shouldBeTrue()
val again = h.executor.startBuild("main", "abc123", h.workingDir) val again = h.executor.startBuild(h.repo, "main", "abc123")
again.artifactKey shouldNotBe first.artifactKey again.artifactKey shouldNotBe first.artifactKey
h.executor.cancel(nightly.artifactKey).shouldBeTrue() h.executor.cancel(nightly.artifactKey).shouldBeTrue()
@@ -303,8 +303,8 @@ class BuildExecutorTest : FunSpec() {
test("a build cancelled while still queued records neither runningSince nor a duration") { test("a build cancelled while still queued records neither runningSince nor a duration") {
val h = harness("sleep 30") val h = harness("sleep 30")
val first = h.executor.startBuild("main", "abc123", h.workingDir) val first = h.executor.startBuild(h.repo, "main", "abc123")
val second = h.executor.startBuild("main", "abc124", h.workingDir) val second = h.executor.startBuild(h.repo, "main", "abc124")
eventually(30.seconds) { eventually(30.seconds) {
h.executor.currentBuilds().map { it.artifactKey } shouldContain first.artifactKey h.executor.currentBuilds().map { it.artifactKey } shouldContain first.artifactKey
} }
@@ -325,7 +325,7 @@ class BuildExecutorTest : FunSpec() {
test("a failing build command records FAILED with a duration") { test("a failing build command records FAILED with a duration") {
val h = harness("exit 3") val h = harness("exit 3")
h.executor.startBuild("main", "abc123", h.workingDir) h.executor.startBuild(h.repo, "main", "abc123")
awaitStatus(h, "main", BuildStatus.FAILED) awaitStatus(h, "main", BuildStatus.FAILED)
awaitIdle(h) awaitIdle(h)
@@ -337,7 +337,7 @@ class BuildExecutorTest : FunSpec() {
test("a failing clean command fails the build without running the build command") { test("a failing clean command fails the build without running the build command") {
val h = harness(buildCommand = "echo forbidden-\$branch", cleanCommand = "exit 1") val h = harness(buildCommand = "echo forbidden-\$branch", cleanCommand = "exit 1")
val build = h.executor.startBuild("main", "abc123", h.workingDir) val build = h.executor.startBuild(h.repo, "main", "abc123")
awaitStatus(h, "main", BuildStatus.FAILED) awaitStatus(h, "main", BuildStatus.FAILED)
awaitIdle(h) awaitIdle(h)
@@ -348,7 +348,7 @@ class BuildExecutorTest : FunSpec() {
test("cancel kills a sleeping process tree and records CANCELLED") { test("cancel kills a sleeping process tree and records CANCELLED") {
val h = harness("echo \$\$ > pid-file; sleep 30 & sleep 30 & wait") val h = harness("echo \$\$ > pid-file; sleep 30 & sleep 30 & wait")
val build = h.executor.startBuild("main", "abc123", h.workingDir) val build = h.executor.startBuild(h.repo, "main", "abc123")
lateinit var root: ProcessHandle lateinit var root: ProcessHandle
var children = emptyList<ProcessHandle>() var children = emptyList<ProcessHandle>()
@@ -376,7 +376,7 @@ class BuildExecutorTest : FunSpec() {
test("shutdown kills an executing build and records INTERRUPTED, not FAILED") { test("shutdown kills an executing build and records INTERRUPTED, not FAILED") {
val h = harness("echo \$\$ > pid-file; sleep 30") val h = harness("echo \$\$ > pid-file; sleep 30")
val build = h.executor.startBuild("main", "abc123", h.workingDir) val build = h.executor.startBuild(h.repo, "main", "abc123")
eventually(10.seconds) { eventually(10.seconds) {
Files.exists(h.workingDir.resolve("pid-file")).shouldBeTrue() Files.exists(h.workingDir.resolve("pid-file")).shouldBeTrue()
} }
@@ -402,8 +402,8 @@ class BuildExecutorTest : FunSpec() {
test("a build still queued at shutdown stays PENDING for the startup recovery") { test("a build still queued at shutdown stays PENDING for the startup recovery") {
val h = harness("sleep 30") val h = harness("sleep 30")
val first = h.executor.startBuild("main", "sha-1", h.workingDir) val first = h.executor.startBuild(h.repo, "main", "sha-1")
val second = h.executor.startBuild("main", "sha-2", h.workingDir) val second = h.executor.startBuild(h.repo, "main", "sha-2")
eventually(10.seconds) { eventually(10.seconds) {
h.repository h.repository
.history() .history()
@@ -432,7 +432,7 @@ class BuildExecutorTest : FunSpec() {
test("shutdown without any build in flight is a no-op") { test("shutdown without any build in flight is a no-op") {
val h = harness("echo ok") val h = harness("echo ok")
h.executor.startBuild("main", "abc123", h.workingDir) h.executor.startBuild(h.repo, "main", "abc123")
awaitStatus(h, "main", BuildStatus.SUCCESS) awaitStatus(h, "main", BuildStatus.SUCCESS)
awaitIdle(h) awaitIdle(h)
@@ -450,7 +450,7 @@ class BuildExecutorTest : FunSpec() {
test("the live log grows while the build is still running") { test("the live log grows while the build is still running") {
val h = harness("echo one-\$branch; sleep 3; echo two-\$branch") val h = harness("echo one-\$branch; sleep 3; echo two-\$branch")
val build = h.executor.startBuild("main", "abc123", h.workingDir) val build = h.executor.startBuild(h.repo, "main", "abc123")
eventually(10.seconds) { eventually(10.seconds) {
Files.readString(build.liveLogFile) shouldContain "one-main" Files.readString(build.liveLogFile) shouldContain "one-main"
@@ -467,7 +467,7 @@ class BuildExecutorTest : FunSpec() {
h.giteaClient.publishStatus(any(), any(), any(), any(), any(), any()) h.giteaClient.publishStatus(any(), any(), any(), any(), any(), any())
} throws RuntimeException("gitea down") } throws RuntimeException("gitea down")
h.executor.startBuild("main", "abc123", h.workingDir) h.executor.startBuild(h.repo, "main", "abc123")
awaitStatus(h, "main", BuildStatus.SUCCESS) awaitStatus(h, "main", BuildStatus.SUCCESS)
} }
@@ -488,8 +488,8 @@ class BuildExecutorTest : FunSpec() {
""".trimIndent(), """.trimIndent(),
) )
h.executor.startBuild("branch-a", "sha-a", h.workingDir) h.executor.startBuild(h.repo, "branch-a", "sha-a")
h.executor.startBuild("branch-b", "sha-b", h.workingDir) h.executor.startBuild(h.repo, "branch-b", "sha-b")
h.repository.latestFor("branch-b")?.status shouldBe BuildStatus.PENDING h.repository.latestFor("branch-b")?.status shouldBe BuildStatus.PENDING
@@ -503,8 +503,8 @@ class BuildExecutorTest : FunSpec() {
test("with maxConcurrent 2 two branches build at the same time") { test("with maxConcurrent 2 two branches build at the same time") {
val h = harness("sleep 10", maxConcurrent = 2) val h = harness("sleep 10", maxConcurrent = 2)
val buildA = h.executor.startBuild("branch-a", "sha-a", h.workingDir) val buildA = h.executor.startBuild(h.repo, "branch-a", "sha-a")
val buildB = h.executor.startBuild("branch-b", "sha-b", h.workingDir) val buildB = h.executor.startBuild(h.repo, "branch-b", "sha-b")
eventually(10.seconds) { eventually(10.seconds) {
h.repository.latestFor("branch-a")?.status shouldBe BuildStatus.RUNNING h.repository.latestFor("branch-a")?.status shouldBe BuildStatus.RUNNING
@@ -522,8 +522,8 @@ class BuildExecutorTest : FunSpec() {
test("a second build of the same branch waits even when a slot is free") { test("a second build of the same branch waits even when a slot is free") {
val h = harness("sleep 1", maxConcurrent = 2) val h = harness("sleep 1", maxConcurrent = 2)
val first = h.executor.startBuild("main", "sha-1", h.workingDir) val first = h.executor.startBuild(h.repo, "main", "sha-1")
val second = h.executor.startBuild("main", "sha-2", h.workingDir) val second = h.executor.startBuild(h.repo, "main", "sha-2")
eventually(30.seconds) { eventually(30.seconds) {
h.repository h.repository
@@ -539,8 +539,8 @@ class BuildExecutorTest : FunSpec() {
test("cancel only affects the addressed build, other branches keep running") { test("cancel only affects the addressed build, other branches keep running") {
val h = harness("sleep 10", maxConcurrent = 2) val h = harness("sleep 10", maxConcurrent = 2)
val buildA = h.executor.startBuild("branch-a", "sha-a", h.workingDir) val buildA = h.executor.startBuild(h.repo, "branch-a", "sha-a")
val buildB = h.executor.startBuild("branch-b", "sha-b", h.workingDir) val buildB = h.executor.startBuild(h.repo, "branch-b", "sha-b")
eventually(10.seconds) { eventually(10.seconds) {
h.repository.latestFor("branch-a")?.status shouldBe BuildStatus.RUNNING h.repository.latestFor("branch-a")?.status shouldBe BuildStatus.RUNNING
h.repository.latestFor("branch-b")?.status shouldBe BuildStatus.RUNNING h.repository.latestFor("branch-b")?.status shouldBe BuildStatus.RUNNING
@@ -2,6 +2,7 @@ package de.hoennig.werkator.commands
import de.hoennig.werkator.build.BuildStatus import de.hoennig.werkator.build.BuildStatus
import de.hoennig.werkator.git.GitService import de.hoennig.werkator.git.GitService
import de.hoennig.werkator.repo.RepoContext
import io.kotest.core.spec.style.FunSpec import io.kotest.core.spec.style.FunSpec
import io.kotest.matchers.shouldBe import io.kotest.matchers.shouldBe
import io.kotest.matchers.string.shouldContain import io.kotest.matchers.string.shouldContain
@@ -18,11 +19,11 @@ class BuildCommandTest : FunSpec() {
private val gitService = mockk<GitService>() private val gitService = mockk<GitService>()
private val consoleBuildRunner = mockk<ConsoleBuildRunner>() private val consoleBuildRunner = mockk<ConsoleBuildRunner>()
private val dir: Path = Paths.get(".") private val dir: Path = Paths.get(".")
private val repo = RepoContext("test", dir, mockk(), mockk())
private fun command(fragment: String? = null) = private fun command(fragment: String? = null) =
BuildCommand(gitService, consoleBuildRunner).apply { BuildCommand(gitService, consoleBuildRunner, repo).apply {
branchFragment = fragment branchFragment = fragment
workingDir = dir
} }
init { init {
@@ -35,13 +36,13 @@ class BuildCommandTest : FunSpec() {
every { gitService.currentBranch(dir) } returns "main" every { gitService.currentBranch(dir) } returns "main"
every { gitService.localHeadCommit("main", dir) } returns "local-head" every { gitService.localHeadCommit("main", dir) } returns "local-head"
every { gitService.hasNewCommits("main", dir) } returns false every { gitService.hasNewCommits("main", dir) } returns false
every { consoleBuildRunner.buildAndStream("main", "local-head", dir) } returns BuildStatus.SUCCESS every { consoleBuildRunner.buildAndStream(repo, "main", "local-head") } returns BuildStatus.SUCCESS
var exitCode = -1 var exitCode = -1
captureConsole { exitCode = command().call() } captureConsole { exitCode = command().call() }
exitCode shouldBe 0 exitCode shouldBe 0
verify { consoleBuildRunner.buildAndStream("main", "local-head", dir) } verify { consoleBuildRunner.buildAndStream(repo, "main", "local-head") }
} }
test("builds origin's head when the branch has new commits on origin") { test("builds origin's head when the branch has new commits on origin") {
@@ -49,20 +50,20 @@ class BuildCommandTest : FunSpec() {
every { gitService.localHeadCommit("main", dir) } returns "local-head" every { gitService.localHeadCommit("main", dir) } returns "local-head"
every { gitService.hasNewCommits("main", dir) } returns true every { gitService.hasNewCommits("main", dir) } returns true
every { gitService.originHeadCommit("main", dir) } returns "origin-head" every { gitService.originHeadCommit("main", dir) } returns "origin-head"
every { consoleBuildRunner.buildAndStream("main", "origin-head", dir) } returns BuildStatus.SUCCESS every { consoleBuildRunner.buildAndStream(repo, "main", "origin-head") } returns BuildStatus.SUCCESS
var exitCode = -1 var exitCode = -1
captureConsole { exitCode = command().call() } captureConsole { exitCode = command().call() }
exitCode shouldBe 0 exitCode shouldBe 0
verify { consoleBuildRunner.buildAndStream("main", "origin-head", dir) } verify { consoleBuildRunner.buildAndStream(repo, "main", "origin-head") }
} }
test("a failing build exits with code 1") { test("a failing build exits with code 1") {
every { gitService.currentBranch(dir) } returns "main" every { gitService.currentBranch(dir) } returns "main"
every { gitService.localHeadCommit("main", dir) } returns "local-head" every { gitService.localHeadCommit("main", dir) } returns "local-head"
every { gitService.hasNewCommits("main", dir) } returns false every { gitService.hasNewCommits("main", dir) } returns false
every { consoleBuildRunner.buildAndStream("main", "local-head", dir) } returns BuildStatus.FAILED every { consoleBuildRunner.buildAndStream(repo, "main", "local-head") } returns BuildStatus.FAILED
var exitCode = -1 var exitCode = -1
captureConsole { exitCode = command().call() } captureConsole { exitCode = command().call() }
@@ -75,13 +76,13 @@ class BuildCommandTest : FunSpec() {
every { gitService.originBranches(dir) } returns listOf("main", "feature/x") every { gitService.originBranches(dir) } returns listOf("main", "feature/x")
every { gitService.localHeadCommit("feature/x", dir) } returns null every { gitService.localHeadCommit("feature/x", dir) } returns null
every { gitService.originHeadCommit("feature/x", dir) } returns "origin-head" every { gitService.originHeadCommit("feature/x", dir) } returns "origin-head"
every { consoleBuildRunner.buildAndStream("feature/x", "origin-head", dir) } returns BuildStatus.SUCCESS every { consoleBuildRunner.buildAndStream(repo, "feature/x", "origin-head") } returns BuildStatus.SUCCESS
var exitCode = -1 var exitCode = -1
captureConsole { exitCode = command(fragment = "x").call() } captureConsole { exitCode = command(fragment = "x").call() }
exitCode shouldBe 0 exitCode shouldBe 0
verify { consoleBuildRunner.buildAndStream("feature/x", "origin-head", dir) } verify { consoleBuildRunner.buildAndStream(repo, "feature/x", "origin-head") }
} }
test("an ambiguous fragment lists the candidates and exits with code 2") { test("an ambiguous fragment lists the candidates and exits with code 2") {
@@ -126,7 +127,7 @@ class BuildCommandTest : FunSpec() {
every { gitService.currentBranch(dir) } returns "main" every { gitService.currentBranch(dir) } returns "main"
every { gitService.localHeadCommit("main", dir) } returns "local-head" every { gitService.localHeadCommit("main", dir) } returns "local-head"
every { gitService.hasNewCommits("main", dir) } returns false every { gitService.hasNewCommits("main", dir) } returns false
every { consoleBuildRunner.buildAndStream("main", "local-head", dir) } returns BuildStatus.SUCCESS every { consoleBuildRunner.buildAndStream(repo, "main", "local-head") } returns BuildStatus.SUCCESS
var exitCode = -1 var exitCode = -1
val console = captureConsole { exitCode = command().call() } val console = captureConsole { exitCode = command().call() }
@@ -6,6 +6,7 @@ import de.hoennig.werkator.build.BuildResult
import de.hoennig.werkator.build.BuildResultRepository import de.hoennig.werkator.build.BuildResultRepository
import de.hoennig.werkator.build.BuildStatus import de.hoennig.werkator.build.BuildStatus
import de.hoennig.werkator.build.RunningBuild import de.hoennig.werkator.build.RunningBuild
import de.hoennig.werkator.repo.RepoContext
import io.kotest.core.spec.style.FunSpec import io.kotest.core.spec.style.FunSpec
import io.kotest.matchers.shouldBe import io.kotest.matchers.shouldBe
import io.kotest.matchers.string.shouldContain import io.kotest.matchers.string.shouldContain
@@ -23,9 +24,10 @@ class ConsoleBuildRunnerTest : FunSpec() {
private val artifactStore = mockk<ArtifactStore>() private val artifactStore = mockk<ArtifactStore>()
private lateinit var tempDir: Path private lateinit var tempDir: Path
private lateinit var repo: RepoContext
private fun runner() = private fun runner() =
ConsoleBuildRunner(buildExecutor, repository, artifactStore).apply { ConsoleBuildRunner(buildExecutor).apply {
pollIntervalMillis = 1 pollIntervalMillis = 1
persistTimeoutMillis = 100 persistTimeoutMillis = 100
} }
@@ -56,6 +58,7 @@ class ConsoleBuildRunnerTest : FunSpec() {
beforeEach { beforeEach {
clearMocks(buildExecutor, repository, artifactStore) clearMocks(buildExecutor, repository, artifactStore)
tempDir = Files.createTempDirectory("werkator-console-build-test") tempDir = Files.createTempDirectory("werkator-console-build-test")
repo = RepoContext("test", tempDir, repository, artifactStore)
} }
afterEach { afterEach {
@@ -66,7 +69,7 @@ class ConsoleBuildRunnerTest : FunSpec() {
val stagingDir = Files.createDirectory(tempDir.resolve("staging")) val stagingDir = Files.createDirectory(tempDir.resolve("staging"))
val build = runningBuild(stagingDir) val build = runningBuild(stagingDir)
Files.writeString(build.liveLogFile, "compiling ...\ntests green\n") Files.writeString(build.liveLogFile, "compiling ...\ntests green\n")
every { buildExecutor.startBuild("main", "0123456789abcdef", tempDir) } returns build every { buildExecutor.startBuild(repo, "main", "0123456789abcdef") } returns build
// the terminal status arrives together with the finished persist (staging gone) // the terminal status arrives together with the finished persist (staging gone)
every { repository.history() } answers { every { repository.history() } answers {
stagingDir.toFile().deleteRecursively() stagingDir.toFile().deleteRecursively()
@@ -75,7 +78,7 @@ class ConsoleBuildRunnerTest : FunSpec() {
every { artifactStore.artifactDir("main-key") } returns null every { artifactStore.artifactDir("main-key") } returns null
var status: BuildStatus? = null var status: BuildStatus? = null
val console = captureConsole { status = runner().buildAndStream("main", "0123456789abcdef", tempDir) } val console = captureConsole { status = runner().buildAndStream(repo, "main", "0123456789abcdef") }
status shouldBe BuildStatus.SUCCESS status shouldBe BuildStatus.SUCCESS
console.stdout shouldContain "compiling ...\ntests green\n" console.stdout shouldContain "compiling ...\ntests green\n"
@@ -87,12 +90,12 @@ class ConsoleBuildRunnerTest : FunSpec() {
val build = runningBuild(stagingDir) val build = runningBuild(stagingDir)
val persistedDir = Files.createDirectory(tempDir.resolve("persisted")) val persistedDir = Files.createDirectory(tempDir.resolve("persisted"))
Files.writeString(persistedDir.resolve(BuildExecutor.LIVE_LOG_FILE), "full build output\n") Files.writeString(persistedDir.resolve(BuildExecutor.LIVE_LOG_FILE), "full build output\n")
every { buildExecutor.startBuild("main", "0123456789abcdef", tempDir) } returns build every { buildExecutor.startBuild(repo, "main", "0123456789abcdef") } returns build
every { repository.history() } returns listOf(result(BuildStatus.FAILED)) every { repository.history() } returns listOf(result(BuildStatus.FAILED))
every { artifactStore.artifactDir("main-key") } returns persistedDir every { artifactStore.artifactDir("main-key") } returns persistedDir
var status: BuildStatus? = null var status: BuildStatus? = null
val console = captureConsole { status = runner().buildAndStream("main", "0123456789abcdef", tempDir) } val console = captureConsole { status = runner().buildAndStream(repo, "main", "0123456789abcdef") }
status shouldBe BuildStatus.FAILED status shouldBe BuildStatus.FAILED
console.stdout shouldContain "full build output" console.stdout shouldContain "full build output"
@@ -103,11 +106,11 @@ class ConsoleBuildRunnerTest : FunSpec() {
val stagingDir = Files.createDirectory(tempDir.resolve("staging")) val stagingDir = Files.createDirectory(tempDir.resolve("staging"))
val build = runningBuild(stagingDir) val build = runningBuild(stagingDir)
Files.writeString(build.liveLogFile, "some output\n") Files.writeString(build.liveLogFile, "some output\n")
every { buildExecutor.startBuild("main", "0123456789abcdef", tempDir) } returns build every { buildExecutor.startBuild(repo, "main", "0123456789abcdef") } returns build
every { repository.history() } returns listOf(result(BuildStatus.SUCCESS, duration = null)) every { repository.history() } returns listOf(result(BuildStatus.SUCCESS, duration = null))
var status: BuildStatus? = null var status: BuildStatus? = null
val console = captureConsole { status = runner().buildAndStream("main", "0123456789abcdef", tempDir) } val console = captureConsole { status = runner().buildAndStream(repo, "main", "0123456789abcdef") }
status shouldBe BuildStatus.SUCCESS status shouldBe BuildStatus.SUCCESS
console.stdout shouldContain "some output" console.stdout shouldContain "some output"
@@ -4,6 +4,7 @@ import de.hoennig.werkator.build.BuildResult
import de.hoennig.werkator.build.BuildResultRepository import de.hoennig.werkator.build.BuildResultRepository
import de.hoennig.werkator.build.BuildStatus import de.hoennig.werkator.build.BuildStatus
import de.hoennig.werkator.git.GitService import de.hoennig.werkator.git.GitService
import de.hoennig.werkator.repo.RepoContext
import io.kotest.core.spec.style.FunSpec import io.kotest.core.spec.style.FunSpec
import io.kotest.matchers.shouldBe import io.kotest.matchers.shouldBe
import io.kotest.matchers.string.shouldContain import io.kotest.matchers.string.shouldContain
@@ -22,8 +23,9 @@ class RetryCommandTest : FunSpec() {
private val repository = mockk<BuildResultRepository>() private val repository = mockk<BuildResultRepository>()
private val consoleBuildRunner = mockk<ConsoleBuildRunner>() private val consoleBuildRunner = mockk<ConsoleBuildRunner>()
private val dir: Path = Paths.get(".") private val dir: Path = Paths.get(".")
private val repo = RepoContext("test", dir, repository, mockk())
private fun command() = RetryCommand(gitService, repository, consoleBuildRunner).apply { workingDir = dir } private fun command() = RetryCommand(gitService, consoleBuildRunner, repo)
private fun result( private fun result(
branch: String, branch: String,
@@ -52,21 +54,21 @@ class RetryCommandTest : FunSpec() {
) )
every { gitService.originHeadCommit("main", dir) } returns "head-main" every { gitService.originHeadCommit("main", dir) } returns "head-main"
every { gitService.originHeadCommit("feature/y", dir) } returns "head-y" every { gitService.originHeadCommit("feature/y", dir) } returns "head-y"
every { consoleBuildRunner.buildAndStream(any(), any(), dir, any()) } returns BuildStatus.SUCCESS every { consoleBuildRunner.buildAndStream(repo, any(), any(), any()) } returns BuildStatus.SUCCESS
var exitCode = -1 var exitCode = -1
captureConsole { exitCode = command().call() } captureConsole { exitCode = command().call() }
exitCode shouldBe 0 exitCode shouldBe 0
verify { consoleBuildRunner.buildAndStream("main", "head-main", dir, "default") } verify { consoleBuildRunner.buildAndStream(repo, "main", "head-main", "default") }
verify { consoleBuildRunner.buildAndStream("feature/y", "head-y", dir, "default") } verify { consoleBuildRunner.buildAndStream(repo, "feature/y", "head-y", "default") }
verify(exactly = 0) { consoleBuildRunner.buildAndStream("feature/ok", any(), dir, any()) } verify(exactly = 0) { consoleBuildRunner.buildAndStream(repo, "feature/ok", any(), any()) }
} }
test("exits with code 1 when a retried build fails again") { test("exits with code 1 when a retried build fails again") {
every { repository.latestPerName() } returns listOf(result("main", BuildStatus.FAILED)) every { repository.latestPerName() } returns listOf(result("main", BuildStatus.FAILED))
every { gitService.originHeadCommit("main", dir) } returns "head-main" every { gitService.originHeadCommit("main", dir) } returns "head-main"
every { consoleBuildRunner.buildAndStream("main", "head-main", dir) } returns BuildStatus.FAILED every { consoleBuildRunner.buildAndStream(repo, "main", "head-main") } returns BuildStatus.FAILED
var exitCode = -1 var exitCode = -1
captureConsole { exitCode = command().call() } captureConsole { exitCode = command().call() }
@@ -0,0 +1,41 @@
package de.hoennig.werkator.repo
import de.hoennig.werkator.config.ConfigLoader
import io.kotest.core.spec.style.FunSpec
import io.kotest.matchers.shouldBe
import java.nio.file.Files
import java.nio.file.Paths
class RepoContextsTest : FunSpec() {
private val contexts = RepoContexts(ConfigLoader())
init {
test("a context is named after its directory and keeps its state inside the repository") {
val dir = Files.createTempDirectory("werkator-repo-context-test").resolve("werkbaum")
Files.createDirectories(dir)
val repo = contexts.open(dir)
repo.name shouldBe "werkbaum"
repo.workingDir shouldBe dir
repo.artifactStore
.rootDir()
.fileName
.toString() shouldBe
de.hoennig.werkator.build.ArtifactKeys
.repoKey(dir)
}
test("the current directory resolves to the same name as its absolute path") {
contexts.open(Paths.get(".")).name shouldBe RepoContexts.defaultName(Paths.get(".").toAbsolutePath())
}
test("a filesystem root has no basename and gets the fallback name") {
RepoContexts.defaultName(Paths.get("/")) shouldBe "repository"
}
test("the name can be overridden per entry, as the registry will do") {
contexts.open(Paths.get("."), name = "custom").name shouldBe "custom"
}
}
}
@@ -4,6 +4,7 @@ import de.hoennig.werkator.build.BuildResult
import de.hoennig.werkator.build.BuildResultRepository import de.hoennig.werkator.build.BuildResultRepository
import de.hoennig.werkator.build.BuildStatus import de.hoennig.werkator.build.BuildStatus
import de.hoennig.werkator.git.GitService import de.hoennig.werkator.git.GitService
import de.hoennig.werkator.repo.RepoContext
import io.kotest.core.spec.style.FunSpec import io.kotest.core.spec.style.FunSpec
import io.kotest.matchers.shouldBe import io.kotest.matchers.shouldBe
import io.mockk.every import io.mockk.every
@@ -14,7 +15,15 @@ import java.time.Instant
class BranchListingTest : FunSpec() { class BranchListingTest : FunSpec() {
private val gitService = mockk<GitService>() private val gitService = mockk<GitService>()
private val repository = mockk<BuildResultRepository>() private val repository = mockk<BuildResultRepository>()
private val listing = BranchListing(gitService, repository) private val repo =
RepoContext(
"test",
java.nio.file.Paths
.get("."),
repository,
mockk(),
)
private val listing = BranchListing(gitService)
private val mainResult = private val mainResult =
BuildResult( BuildResult(
@@ -38,7 +47,7 @@ class BranchListingTest : FunSpec() {
every { repository.latestFor(any()) } returns null every { repository.latestFor(any()) } returns null
every { repository.latestGreenFor(any()) } returns null every { repository.latestGreenFor(any()) } returns null
val rows = listing.branches() val rows = listing.branches(repo)
// no bare "master" row next to master@release — it would read as "never built" // no bare "master" row next to master@release — it would read as "never built"
rows.map { it.name } shouldBe listOf("master@release", "idle") rows.map { it.name } shouldBe listOf("master@release", "idle")
@@ -56,7 +65,7 @@ class BranchListingTest : FunSpec() {
every { repository.latestFor(any()) } returns null every { repository.latestFor(any()) } returns null
every { repository.latestGreenFor(any()) } returns null every { repository.latestGreenFor(any()) } returns null
listing.branches().map { it.branch } shouldBe listing.branches(repo).map { it.branch } shouldBe
listOf("main", "develop", "zz-flat", "aa/nested", "feature/x") listOf("main", "develop", "zz-flat", "aa/nested", "feature/x")
} }
@@ -68,7 +77,7 @@ class BranchListingTest : FunSpec() {
every { repository.latestFor("feature/x") } returns null every { repository.latestFor("feature/x") } returns null
every { repository.latestGreenFor("feature/x") } returns null every { repository.latestGreenFor("feature/x") } returns null
val branches = listing.branches() val branches = listing.branches(repo)
branches[0].branch shouldBe "main" branches[0].branch shouldBe "main"
branches[0].status shouldBe "success" branches[0].status shouldBe "success"
@@ -90,7 +99,7 @@ class BranchListingTest : FunSpec() {
every { repository.latestGreenFor("feature/x") } returns every { repository.latestGreenFor("feature/x") } returns
mainResult.copy(branch = "feature/x", name = "feature/x", artifactKey = "green-key") mainResult.copy(branch = "feature/x", name = "feature/x", artifactKey = "green-key")
val branches = listing.branches() val branches = listing.branches(repo)
branches[0].status shouldBe "failed" branches[0].status shouldBe "failed"
branches[0].latestGreenUrl shouldBe null branches[0].latestGreenUrl shouldBe null
@@ -107,7 +116,7 @@ class BranchListingTest : FunSpec() {
every { repository.latestGreenFor("develop") } returns null every { repository.latestGreenFor("develop") } returns null
every { repository.latestPerName() } returns listOf(mainResult, nightly) every { repository.latestPerName() } returns listOf(mainResult, nightly)
val rows = listing.branches() val rows = listing.branches(repo)
rows.map { it.name } shouldBe listOf("main", "main@nightly", "develop") rows.map { it.name } shouldBe listOf("main", "main@nightly", "develop")
rows[1].branch shouldBe "main" rows[1].branch shouldBe "main"
@@ -8,6 +8,7 @@ import de.hoennig.werkator.build.BuildResultRepository
import de.hoennig.werkator.build.BuildStatus import de.hoennig.werkator.build.BuildStatus
import de.hoennig.werkator.build.RunningBuild import de.hoennig.werkator.build.RunningBuild
import de.hoennig.werkator.git.GitService import de.hoennig.werkator.git.GitService
import de.hoennig.werkator.repo.RepoContext
import io.kotest.core.spec.style.FunSpec import io.kotest.core.spec.style.FunSpec
import io.mockk.clearMocks import io.mockk.clearMocks
import io.mockk.every import io.mockk.every
@@ -50,6 +51,9 @@ class BuildsApiControllerTest : FunSpec() {
@MockkBean @MockkBean
lateinit var branchListing: BranchListing lateinit var branchListing: BranchListing
@MockkBean
lateinit var repo: RepoContext
private val startedAt = Instant.parse("2026-07-07T10:00:00Z") private val startedAt = Instant.parse("2026-07-07T10:00:00Z")
private val successResult = private val successResult =
@@ -74,7 +78,8 @@ class BuildsApiControllerTest : FunSpec() {
init { init {
beforeEach { beforeEach {
clearMocks(repository, buildExecutor, artifactStore, controlTokens, gitService, branchListing) clearMocks(repository, buildExecutor, artifactStore, controlTokens, gitService, branchListing, repo)
every { repo.workingDir } returns tempDir
every { controlTokens.matches(any()) } answers { firstArg<String?>() == "secret" } every { controlTokens.matches(any()) } answers { firstArg<String?>() == "secret" }
every { repository.latestGreenFor(any()) } returns null every { repository.latestGreenFor(any()) } returns null
} }
@@ -152,7 +157,7 @@ class BuildsApiControllerTest : FunSpec() {
test("restart enqueues the branch's last recorded commit, also for branch names with slashes") { test("restart enqueues the branch's last recorded commit, also for branch names with slashes") {
val liveLogFile = tempDir.resolve("restart.log") val liveLogFile = tempDir.resolve("restart.log")
every { repository.latestFor("feature/topic") } returns successResult.copy(branch = "feature/topic", name = "feature/topic") every { repository.latestFor("feature/topic") } returns successResult.copy(branch = "feature/topic", name = "feature/topic")
every { buildExecutor.startBuild("feature/topic", successResult.commit) } returns every { buildExecutor.startBuild(repo, "feature/topic", successResult.commit) } returns
runningBuild(liveLogFile).copy(branch = "feature/topic", name = "feature/topic") runningBuild(liveLogFile).copy(branch = "feature/topic", name = "feature/topic")
mockMvc mockMvc
@@ -164,14 +169,14 @@ class BuildsApiControllerTest : FunSpec() {
.andExpect(jsonPath("$.status").value("pending")) .andExpect(jsonPath("$.status").value("pending"))
.andExpect(jsonPath("$.artifactKey").value("main-abc123-running")) .andExpect(jsonPath("$.artifactKey").value("main-abc123-running"))
verify { buildExecutor.startBuild("feature/topic", successResult.commit) } verify { buildExecutor.startBuild(repo, "feature/topic", successResult.commit) }
} }
test("restart of a named build re-runs its build definition on its real branch") { test("restart of a named build re-runs its build definition on its real branch") {
val liveLogFile = tempDir.resolve("named-restart.log") val liveLogFile = tempDir.resolve("named-restart.log")
every { repository.latestFor("main@pitest") } returns every { repository.latestFor("main@pitest") } returns
successResult.copy(build = "pitest", name = "main@pitest") successResult.copy(build = "pitest", name = "main@pitest")
every { buildExecutor.startBuild("main", successResult.commit, build = "pitest") } returns every { buildExecutor.startBuild(repo, "main", successResult.commit, build = "pitest") } returns
runningBuild(liveLogFile).copy(build = "pitest", name = "main@pitest") runningBuild(liveLogFile).copy(build = "pitest", name = "main@pitest")
mockMvc mockMvc
@@ -183,14 +188,14 @@ class BuildsApiControllerTest : FunSpec() {
.andExpect(jsonPath("$.name").value("main@pitest")) .andExpect(jsonPath("$.name").value("main@pitest"))
// the re-run resolves its settings from the current config by the build name // the re-run resolves its settings from the current config by the build name
verify { buildExecutor.startBuild("main", successResult.commit, build = "pitest") } verify { buildExecutor.startBuild(repo, "main", successResult.commit, build = "pitest") }
} }
test("restart with atOriginHead builds the branch as it is now, not the recorded commit") { test("restart with atOriginHead builds the branch as it is now, not the recorded commit") {
val liveLogFile = tempDir.resolve("head-restart.log") val liveLogFile = tempDir.resolve("head-restart.log")
every { repository.latestFor("main") } returns successResult every { repository.latestFor("main") } returns successResult
every { gitService.originHeadCommit("main", any()) } returns "newhead1" every { gitService.originHeadCommit("main", any()) } returns "newhead1"
every { buildExecutor.startBuild("main", "newhead1") } returns runningBuild(liveLogFile) every { buildExecutor.startBuild(repo, "main", "newhead1") } returns runningBuild(liveLogFile)
mockMvc mockMvc
.perform( .perform(
@@ -201,15 +206,15 @@ class BuildsApiControllerTest : FunSpec() {
).andExpect(status().isAccepted) ).andExpect(status().isAccepted)
// the recorded commit is deliberately not used: a branches row stands for a branch // the recorded commit is deliberately not used: a branches row stands for a branch
verify { buildExecutor.startBuild("main", "newhead1") } verify { buildExecutor.startBuild(repo, "main", "newhead1") }
verify(exactly = 0) { buildExecutor.startBuild("main", successResult.commit) } verify(exactly = 0) { buildExecutor.startBuild(repo, "main", successResult.commit) }
} }
test("restart with atOriginHead keeps the recorded build definition and its real branch") { test("restart with atOriginHead keeps the recorded build definition and its real branch") {
val liveLogFile = tempDir.resolve("head-named.log") val liveLogFile = tempDir.resolve("head-named.log")
every { repository.latestFor("main@pitest") } returns successResult.copy(build = "pitest", name = "main@pitest") every { repository.latestFor("main@pitest") } returns successResult.copy(build = "pitest", name = "main@pitest")
every { gitService.originHeadCommit("main", any()) } returns "newhead2" every { gitService.originHeadCommit("main", any()) } returns "newhead2"
every { buildExecutor.startBuild("main", "newhead2", build = "pitest") } returns every { buildExecutor.startBuild(repo, "main", "newhead2", build = "pitest") } returns
runningBuild(liveLogFile).copy(build = "pitest", name = "main@pitest") runningBuild(liveLogFile).copy(build = "pitest", name = "main@pitest")
mockMvc mockMvc
@@ -220,7 +225,7 @@ class BuildsApiControllerTest : FunSpec() {
.header(BuildsApiController.TOKEN_HEADER, "secret"), .header(BuildsApiController.TOKEN_HEADER, "secret"),
).andExpect(status().isAccepted) ).andExpect(status().isAccepted)
verify { buildExecutor.startBuild("main", "newhead2", build = "pitest") } verify { buildExecutor.startBuild(repo, "main", "newhead2", build = "pitest") }
} }
test("restart with atOriginHead of a branch gone from origin is refused by name") { test("restart with atOriginHead of a branch gone from origin is refused by name") {
@@ -242,7 +247,7 @@ class BuildsApiControllerTest : FunSpec() {
val liveLogFile = tempDir.resolve("first-build.log") val liveLogFile = tempDir.resolve("first-build.log")
every { repository.latestFor("fresh") } returns null every { repository.latestFor("fresh") } returns null
every { gitService.originHeadCommit("fresh", any()) } returns successResult.commit every { gitService.originHeadCommit("fresh", any()) } returns successResult.commit
every { buildExecutor.startBuild("fresh", successResult.commit) } returns every { buildExecutor.startBuild(repo, "fresh", successResult.commit) } returns
runningBuild(liveLogFile).copy(branch = "fresh", name = "fresh") runningBuild(liveLogFile).copy(branch = "fresh", name = "fresh")
mockMvc mockMvc
@@ -250,7 +255,7 @@ class BuildsApiControllerTest : FunSpec() {
.andExpect(status().isAccepted) .andExpect(status().isAccepted)
.andExpect(jsonPath("$.status").value("pending")) .andExpect(jsonPath("$.status").value("pending"))
verify { buildExecutor.startBuild("fresh", successResult.commit) } verify { buildExecutor.startBuild(repo, "fresh", successResult.commit) }
} }
test("restart of a branch without recorded builds and without origin counterpart answers 404") { test("restart of a branch without recorded builds and without origin counterpart answers 404") {
@@ -290,7 +295,7 @@ class BuildsApiControllerTest : FunSpec() {
.header(BuildsApiController.TOKEN_HEADER, "wrong"), .header(BuildsApiController.TOKEN_HEADER, "wrong"),
).andExpect(status().isForbidden) ).andExpect(status().isForbidden)
verify(exactly = 0) { buildExecutor.startBuild(any(), any(), any()) } verify(exactly = 0) { buildExecutor.startBuild(any(), any(), any(), any()) }
} }
test("cancel answers 202 for a cancellable build and 404 otherwise") { test("cancel answers 202 for a cancellable build and 404 otherwise") {
@@ -317,7 +322,7 @@ class BuildsApiControllerTest : FunSpec() {
.perform(delete("/api/builds/some-key").param("token", "secret")) .perform(delete("/api/builds/some-key").param("token", "secret"))
.andExpect(status().isForbidden) .andExpect(status().isForbidden)
verify(exactly = 0) { buildExecutor.startBuild(any(), any(), any()) } verify(exactly = 0) { buildExecutor.startBuild(any(), any(), any(), any()) }
verify(exactly = 0) { buildExecutor.cancel(any()) } verify(exactly = 0) { buildExecutor.cancel(any()) }
verify(exactly = 0) { repository.delete(any()) } verify(exactly = 0) { repository.delete(any()) }
} }
@@ -10,6 +10,7 @@ import de.hoennig.werkator.config.ConfigLoader
import de.hoennig.werkator.config.WerkatorConfig import de.hoennig.werkator.config.WerkatorConfig
import de.hoennig.werkator.git.GitService import de.hoennig.werkator.git.GitService
import de.hoennig.werkator.metrics.SystemMetricsCollector import de.hoennig.werkator.metrics.SystemMetricsCollector
import de.hoennig.werkator.repo.RepoContext
import io.kotest.core.spec.style.FunSpec import io.kotest.core.spec.style.FunSpec
import io.mockk.clearMocks import io.mockk.clearMocks
import io.mockk.every import io.mockk.every
@@ -22,6 +23,7 @@ import org.springframework.test.web.servlet.result.MockMvcResultMatchers.content
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status
import java.nio.file.Files import java.nio.file.Files
import java.nio.file.Path import java.nio.file.Path
import java.nio.file.Paths
import java.time.Duration import java.time.Duration
import java.time.Instant import java.time.Instant
@@ -65,6 +67,9 @@ class PermanentBranchRoutesTest : FunSpec() {
@MockkBean @MockkBean
lateinit var branchPermalinks: BranchPermalinks lateinit var branchPermalinks: BranchPermalinks
@MockkBean
lateinit var repo: RepoContext
private val artifactDir: Path = Files.createTempDirectory("werkator-permanent-routes-test") private val artifactDir: Path = Files.createTempDirectory("werkator-permanent-routes-test")
private val greenBuild = private val greenBuild =
@@ -89,7 +94,9 @@ class PermanentBranchRoutesTest : FunSpec() {
metricsCollector, metricsCollector,
branchListing, branchListing,
branchPermalinks, branchPermalinks,
repo,
) )
every { repo.workingDir } returns Paths.get(".")
every { configLoader.load(any()) } returns WerkatorConfig() every { configLoader.load(any()) } returns WerkatorConfig()
every { configLoader.loadWithBranchLayer(any(), anyNullable()) } returns WerkatorConfig() every { configLoader.loadWithBranchLayer(any(), anyNullable()) } returns WerkatorConfig()
every { gitService.showFileAtCommit(any(), any(), any()) } returns null every { gitService.showFileAtCommit(any(), any(), any()) } returns null
@@ -17,6 +17,7 @@ import de.hoennig.werkator.git.GitService
import de.hoennig.werkator.metrics.MetricAggregate import de.hoennig.werkator.metrics.MetricAggregate
import de.hoennig.werkator.metrics.SystemMetrics import de.hoennig.werkator.metrics.SystemMetrics
import de.hoennig.werkator.metrics.SystemMetricsCollector import de.hoennig.werkator.metrics.SystemMetricsCollector
import de.hoennig.werkator.repo.RepoContext
import io.kotest.core.spec.style.FunSpec import io.kotest.core.spec.style.FunSpec
import io.kotest.matchers.shouldBe import io.kotest.matchers.shouldBe
import io.kotest.matchers.string.shouldContain import io.kotest.matchers.string.shouldContain
@@ -36,6 +37,7 @@ import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status
import org.springframework.web.server.ResponseStatusException import org.springframework.web.server.ResponseStatusException
import java.nio.file.Files import java.nio.file.Files
import java.nio.file.Path import java.nio.file.Path
import java.nio.file.Paths
import java.time.Duration import java.time.Duration
import java.time.Instant import java.time.Instant
@@ -73,6 +75,9 @@ class UiControllerTest : FunSpec() {
@MockkBean @MockkBean
lateinit var branchPermalinks: BranchPermalinks lateinit var branchPermalinks: BranchPermalinks
@MockkBean
lateinit var repo: RepoContext
private val startedAt = Instant.parse("2026-07-07T10:00:00Z") private val startedAt = Instant.parse("2026-07-07T10:00:00Z")
private val emptySystemMetrics = private val emptySystemMetrics =
@@ -113,7 +118,9 @@ class UiControllerTest : FunSpec() {
metricsCollector, metricsCollector,
branchListing, branchListing,
branchPermalinks, branchPermalinks,
repo,
) )
every { repo.workingDir } returns Paths.get(".")
every { configLoader.load(any()) } returns every { configLoader.load(any()) } returns
WerkatorConfig( WerkatorConfig(
server = ServerConfig(impressumUrl = "https://example.org/imprint"), server = ServerConfig(impressumUrl = "https://example.org/imprint"),
@@ -21,6 +21,7 @@ import de.hoennig.werkator.config.TriggerConfig
import de.hoennig.werkator.config.WatcherConfig import de.hoennig.werkator.config.WatcherConfig
import de.hoennig.werkator.config.WerkatorConfig import de.hoennig.werkator.config.WerkatorConfig
import de.hoennig.werkator.git.GitService import de.hoennig.werkator.git.GitService
import de.hoennig.werkator.repo.RepoContext
import io.kotest.assertions.throwables.shouldThrow import io.kotest.assertions.throwables.shouldThrow
import io.kotest.core.spec.style.FunSpec import io.kotest.core.spec.style.FunSpec
import io.kotest.matchers.booleans.shouldBeFalse import io.kotest.matchers.booleans.shouldBeFalse
@@ -62,12 +63,11 @@ class WatcherTest : FunSpec() {
val artifactStore = mockk<ArtifactStore>() val artifactStore = mockk<ArtifactStore>()
val startedBuilds = CopyOnWriteArrayList<Pair<String, String>>() val startedBuilds = CopyOnWriteArrayList<Pair<String, String>>()
val configLoader = mockk<ConfigLoader>() val configLoader = mockk<ConfigLoader>()
val repo = RepoContext("test", workingDir, repository, artifactStore)
val watcher = val watcher =
Watcher( Watcher(
gitService = gitService, gitService = gitService,
buildExecutor = buildExecutor, buildExecutor = buildExecutor,
repository = repository,
artifactStore = artifactStore,
configLoader = configLoader, configLoader = configLoader,
clock = Clock.fixed(noon, ZoneOffset.UTC), clock = Clock.fixed(noon, ZoneOffset.UTC),
) )
@@ -91,8 +91,8 @@ class WatcherTest : FunSpec() {
every { gitService.fastForwardLocalBranches(any()) } returns emptyList() every { gitService.fastForwardLocalBranches(any()) } returns emptyList()
every { buildExecutor.currentBuilds() } returns emptyList() every { buildExecutor.currentBuilds() } returns emptyList()
every { buildExecutor.startBuild(any(), any(), any(), any()) } answers { every { buildExecutor.startBuild(any(), any(), any(), any()) } answers {
val branch = firstArg<String>() val branch = secondArg<String>()
val commit = secondArg<String>() val commit = thirdArg<String>()
startedBuilds += branch to commit startedBuilds += branch to commit
runningBuild(branch, commit) runningBuild(branch, commit)
} }
@@ -159,7 +159,7 @@ class WatcherTest : FunSpec() {
val harness = Harness() val harness = Harness()
every { harness.gitService.fetchOrigin(any()) } throws RuntimeException("origin unreachable") every { harness.gitService.fetchOrigin(any()) } throws RuntimeException("origin unreachable")
harness.watcher.poll(harness.workingDir) harness.watcher.poll(harness.repo)
harness.watcher harness.watcher
.state() .state()
@@ -170,7 +170,7 @@ class WatcherTest : FunSpec() {
verify(exactly = 0) { harness.artifactStore.prune(any()) } verify(exactly = 0) { harness.artifactStore.prune(any()) }
every { harness.gitService.fetchOrigin(any()) } returns Unit every { harness.gitService.fetchOrigin(any()) } returns Unit
harness.watcher.poll(harness.workingDir) harness.watcher.poll(harness.repo)
harness.watcher harness.watcher
.state() .state()
@@ -183,19 +183,19 @@ class WatcherTest : FunSpec() {
val logged = captureWatcherLog() val logged = captureWatcherLog()
every { harness.gitService.fetchOrigin(any()) } throws RuntimeException("origin unreachable") every { harness.gitService.fetchOrigin(any()) } throws RuntimeException("origin unreachable")
repeat(5) { harness.watcher.poll(harness.workingDir) } repeat(5) { harness.watcher.poll(harness.repo) }
// one wrong token used to write a warning every ten seconds, 297 of them in an hour // one wrong token used to write a warning every ten seconds, 297 of them in an hour
logged().filter { it.contains("fetching origin failed") } shouldHaveSize 1 logged().filter { it.contains("fetching origin failed") } shouldHaveSize 1
every { harness.gitService.fetchOrigin(any()) } returns Unit every { harness.gitService.fetchOrigin(any()) } returns Unit
repeat(3) { harness.watcher.poll(harness.workingDir) } repeat(3) { harness.watcher.poll(harness.repo) }
logged().filter { it.contains("fetching origin succeeded again") } shouldHaveSize 1 logged().filter { it.contains("fetching origin succeeded again") } shouldHaveSize 1
// a different failure is a different message and is worth saying again // a different failure is a different message and is worth saying again
every { harness.gitService.fetchOrigin(any()) } throws RuntimeException("host is down") every { harness.gitService.fetchOrigin(any()) } throws RuntimeException("host is down")
harness.watcher.poll(harness.workingDir) harness.watcher.poll(harness.repo)
logged().filter { it.contains("fetching origin failed") } shouldHaveSize 2 logged().filter { it.contains("fetching origin failed") } shouldHaveSize 2
} }
@@ -209,7 +209,7 @@ class WatcherTest : FunSpec() {
every { harness.gitService.originHeadCommit("main", any()) } returns "commit-main" every { harness.gitService.originHeadCommit("main", any()) } returns "commit-main"
every { harness.gitService.originHeadCommit("feature/new", any()) } returns "commit-feature" every { harness.gitService.originHeadCommit("feature/new", any()) } returns "commit-feature"
harness.watcher.poll(harness.workingDir) harness.watcher.poll(harness.repo)
harness.startedBuilds shouldContainExactly harness.startedBuilds shouldContainExactly
listOf("main" to "commit-main", "feature/new" to "commit-feature") listOf("main" to "commit-main", "feature/new" to "commit-feature")
@@ -223,7 +223,7 @@ class WatcherTest : FunSpec() {
every { harness.gitService.originHeadCommit("main", any()) } returns "commit-main" every { harness.gitService.originHeadCommit("main", any()) } returns "commit-main"
every { harness.gitService.fastForwardLocalBranches(any()) } returns listOf("main") every { harness.gitService.fastForwardLocalBranches(any()) } returns listOf("main")
harness.watcher.poll(harness.workingDir) harness.watcher.poll(harness.repo)
// syncing the ref before the decision would hide the very commit being enqueued here // syncing the ref before the decision would hide the very commit being enqueued here
harness.startedBuilds shouldContainExactly listOf("main" to "commit-main") harness.startedBuilds shouldContainExactly listOf("main" to "commit-main")
@@ -238,7 +238,7 @@ class WatcherTest : FunSpec() {
every { harness.gitService.originBranches(any()) } returns listOf("main") every { harness.gitService.originBranches(any()) } returns listOf("main")
every { harness.gitService.fastForwardLocalBranches(any()) } throws RuntimeException("ref locked") every { harness.gitService.fastForwardLocalBranches(any()) } throws RuntimeException("ref locked")
harness.watcher.poll(harness.workingDir) harness.watcher.poll(harness.repo)
harness.watcher harness.watcher
.state() .state()
@@ -251,7 +251,7 @@ class WatcherTest : FunSpec() {
val harness = Harness(WerkatorConfig(watcher = WatcherConfig(fastForwardLocalRefs = false))) val harness = Harness(WerkatorConfig(watcher = WatcherConfig(fastForwardLocalRefs = false)))
every { harness.gitService.originBranches(any()) } returns listOf("main") every { harness.gitService.originBranches(any()) } returns listOf("main")
harness.watcher.poll(harness.workingDir) harness.watcher.poll(harness.repo)
verify(exactly = 0) { harness.gitService.fastForwardLocalBranches(any()) } verify(exactly = 0) { harness.gitService.fastForwardLocalBranches(any()) }
} }
@@ -264,7 +264,7 @@ class WatcherTest : FunSpec() {
every { harness.gitService.hasNewCommits("main", any()) } returns true every { harness.gitService.hasNewCommits("main", any()) } returns true
every { harness.gitService.originHeadCommit("main", any()) } returns "commit-new" every { harness.gitService.originHeadCommit("main", any()) } returns "commit-new"
harness.watcher.poll(harness.workingDir) harness.watcher.poll(harness.repo)
harness.startedBuilds.shouldBeEmpty() harness.startedBuilds.shouldBeEmpty()
} }
@@ -280,7 +280,7 @@ class WatcherTest : FunSpec() {
every { harness.gitService.originHeadCommit("main", any()) } returns "commit-2" every { harness.gitService.originHeadCommit("main", any()) } returns "commit-2"
every { harness.gitService.originHeadCommit("feature/other", any()) } returns "commit-3" every { harness.gitService.originHeadCommit("feature/other", any()) } returns "commit-3"
harness.watcher.poll(harness.workingDir) harness.watcher.poll(harness.repo)
harness.startedBuilds shouldContainExactly listOf("feature/other" to "commit-3") harness.startedBuilds shouldContainExactly listOf("feature/other" to "commit-3")
harness.watcher.state().queuedBranches shouldContainExactly listOf("main") harness.watcher.state().queuedBranches shouldContainExactly listOf("main")
@@ -294,11 +294,11 @@ class WatcherTest : FunSpec() {
every { harness.gitService.hasNewCommits("main", any()) } returns true every { harness.gitService.hasNewCommits("main", any()) } returns true
every { harness.gitService.originHeadCommit("main", any()) } returns "commit-abc" every { harness.gitService.originHeadCommit("main", any()) } returns "commit-abc"
harness.watcher.poll(harness.workingDir) harness.watcher.poll(harness.repo)
harness.startedBuilds.shouldBeEmpty() harness.startedBuilds.shouldBeEmpty()
every { harness.gitService.originHeadCommit("main", any()) } returns "commit-def" every { harness.gitService.originHeadCommit("main", any()) } returns "commit-def"
harness.watcher.poll(harness.workingDir) harness.watcher.poll(harness.repo)
harness.startedBuilds shouldContainExactly listOf("main" to "commit-def") harness.startedBuilds shouldContainExactly listOf("main" to "commit-def")
} }
@@ -306,7 +306,7 @@ class WatcherTest : FunSpec() {
test("poll filters new origin branches by the configured newBranchMaxAge") { test("poll filters new origin branches by the configured newBranchMaxAge") {
val harness = Harness(WerkatorConfig(watcher = WatcherConfig(newBranchMaxAge = "12h"))) val harness = Harness(WerkatorConfig(watcher = WatcherConfig(newBranchMaxAge = "12h")))
harness.watcher.poll(harness.workingDir) harness.watcher.poll(harness.repo)
verify { harness.gitService.newOriginBranches(Duration.ofHours(12), any()) } verify { harness.gitService.newOriginBranches(Duration.ofHours(12), any()) }
} }
@@ -319,7 +319,7 @@ class WatcherTest : FunSpec() {
every { harness.gitService.originHeadCommit("feature/no-pr", any()) } returns "commit-solo" every { harness.gitService.originHeadCommit("feature/no-pr", any()) } returns "commit-solo"
every { harness.gitService.pullRequestHeads(any()) } returns setOf("commit-pr") every { harness.gitService.pullRequestHeads(any()) } returns setOf("commit-pr")
harness.watcher.poll(harness.workingDir) harness.watcher.poll(harness.repo)
harness.startedBuilds shouldContainExactly listOf("feature/pr" to "commit-pr") harness.startedBuilds shouldContainExactly listOf("feature/pr" to "commit-pr")
} }
@@ -330,7 +330,7 @@ class WatcherTest : FunSpec() {
every { harness.gitService.newOriginBranches(any(), any()) } returns listOf("feature/x") every { harness.gitService.newOriginBranches(any(), any()) } returns listOf("feature/x")
every { harness.gitService.originHeadCommit("feature/x", any()) } returns "commit-x" every { harness.gitService.originHeadCommit("feature/x", any()) } returns "commit-x"
harness.watcher.poll(harness.workingDir) harness.watcher.poll(harness.repo)
harness.startedBuilds shouldContainExactly listOf("feature/x" to "commit-x") harness.startedBuilds shouldContainExactly listOf("feature/x" to "commit-x")
verify(exactly = 0) { harness.gitService.pullRequestHeads(any()) } verify(exactly = 0) { harness.gitService.pullRequestHeads(any()) }
@@ -348,7 +348,7 @@ class WatcherTest : FunSpec() {
every { harness.gitService.newOriginBranches(any(), any()) } returns listOf("feature/no-pr") every { harness.gitService.newOriginBranches(any(), any()) } returns listOf("feature/no-pr")
every { harness.gitService.originHeadCommit("feature/no-pr", any()) } returns "commit-solo" every { harness.gitService.originHeadCommit("feature/no-pr", any()) } returns "commit-solo"
harness.watcher.poll(harness.workingDir) harness.watcher.poll(harness.repo)
harness.startedBuilds shouldContainExactly listOf("feature/no-pr" to "commit-solo") harness.startedBuilds shouldContainExactly listOf("feature/no-pr" to "commit-solo")
verify(exactly = 0) { harness.gitService.pullRequestHeads(any()) } verify(exactly = 0) { harness.gitService.pullRequestHeads(any()) }
@@ -370,7 +370,7 @@ class WatcherTest : FunSpec() {
every { harness.gitService.hasNewCommits("main", any()) } returns true every { harness.gitService.hasNewCommits("main", any()) } returns true
every { harness.gitService.originHeadCommit("main", any()) } returns "commit-main" every { harness.gitService.originHeadCommit("main", any()) } returns "commit-main"
harness.watcher.poll(harness.workingDir) harness.watcher.poll(harness.repo)
harness.startedBuilds shouldContainExactly listOf("main" to "commit-main") harness.startedBuilds shouldContainExactly listOf("main" to "commit-main")
} }
@@ -394,7 +394,7 @@ class WatcherTest : FunSpec() {
every { harness.gitService.originBranches(any()) } returns listOf("main") every { harness.gitService.originBranches(any()) } returns listOf("main")
every { harness.gitService.originHeadCommit("main", any()) } returns "commit-abc" every { harness.gitService.originHeadCommit("main", any()) } returns "commit-abc"
harness.watcher.poll(harness.workingDir) harness.watcher.poll(harness.repo)
harness.startedBuilds.shouldBeEmpty() harness.startedBuilds.shouldBeEmpty()
harness.autoBuildState().isTriggered("main", LocalDate.parse("2026-07-07"), "11:00").shouldBeFalse() harness.autoBuildState().isTriggered("main", LocalDate.parse("2026-07-07"), "11:00").shouldBeFalse()
@@ -406,12 +406,12 @@ class WatcherTest : FunSpec() {
every { harness.gitService.originBranches(any()) } returns listOf("main") every { harness.gitService.originBranches(any()) } returns listOf("main")
every { harness.gitService.originHeadCommit("main", any()) } returns "commit-abc" every { harness.gitService.originHeadCommit("main", any()) } returns "commit-abc"
harness.watcher.poll(harness.workingDir) harness.watcher.poll(harness.repo)
harness.watcher.poll(harness.workingDir) harness.watcher.poll(harness.repo)
harness.startedBuilds shouldContainExactly listOf("main" to "commit-abc") harness.startedBuilds shouldContainExactly listOf("main" to "commit-abc")
// the deprecated branch schedule rebuilds the branch's own pool with the default build // the deprecated branch schedule rebuilds the branch's own pool with the default build
verify { harness.buildExecutor.startBuild("main", "commit-abc", any(), BuildDefinition.DEFAULT) } verify { harness.buildExecutor.startBuild(harness.repo, "main", "commit-abc", BuildDefinition.DEFAULT) }
harness.autoBuildState().isTriggered("main", LocalDate.parse("2026-07-07"), "11:00").shouldBeTrue() harness.autoBuildState().isTriggered("main", LocalDate.parse("2026-07-07"), "11:00").shouldBeTrue()
} }
@@ -434,13 +434,13 @@ class WatcherTest : FunSpec() {
every { harness.gitService.originHeadCommit("main", any()) } returns "commit-abc" every { harness.gitService.originHeadCommit("main", any()) } returns "commit-abc"
every { harness.gitService.originHeadCommit("release/1.x", any()) } returns "commit-rel" every { harness.gitService.originHeadCommit("release/1.x", any()) } returns "commit-rel"
harness.watcher.poll(harness.workingDir) harness.watcher.poll(harness.repo)
harness.watcher.poll(harness.workingDir) harness.watcher.poll(harness.repo)
// glob selector: main and release/1.x fire once, feature/x is not selected // glob selector: main and release/1.x fire once, feature/x is not selected
harness.startedBuilds shouldContainExactlyInAnyOrder harness.startedBuilds shouldContainExactlyInAnyOrder
listOf("main" to "commit-abc", "release/1.x" to "commit-rel") listOf("main" to "commit-abc", "release/1.x" to "commit-rel")
verify { harness.buildExecutor.startBuild("main", "commit-abc", any(), "pitest") } verify { harness.buildExecutor.startBuild(harness.repo, "main", "commit-abc", "pitest") }
harness.autoBuildState().isTriggered("main@pitest", LocalDate.parse("2026-07-07"), "11:00").shouldBeTrue() harness.autoBuildState().isTriggered("main@pitest", LocalDate.parse("2026-07-07"), "11:00").shouldBeTrue()
} }
@@ -460,10 +460,10 @@ class WatcherTest : FunSpec() {
"dormant" to noon.minus(Duration.ofDays(10)), "dormant" to noon.minus(Duration.ofDays(10)),
) )
harness.watcher.poll(harness.workingDir) harness.watcher.poll(harness.repo)
harness.startedBuilds shouldContainExactly listOf("active" to "commit-act") harness.startedBuilds shouldContainExactly listOf("active" to "commit-act")
verify { harness.buildExecutor.startBuild("active", "commit-act", any(), "pitest") } verify { harness.buildExecutor.startBuild(harness.repo, "active", "commit-act", "pitest") }
} }
test("an onPush build definition builds the changed branches it selects") { test("an onPush build definition builds the changed branches it selects") {
@@ -480,13 +480,13 @@ class WatcherTest : FunSpec() {
every { harness.gitService.originHeadCommit("main", any()) } returns "commit-main" every { harness.gitService.originHeadCommit("main", any()) } returns "commit-main"
every { harness.gitService.originHeadCommit("feature/x", any()) } returns "commit-feat" every { harness.gitService.originHeadCommit("feature/x", any()) } returns "commit-feat"
harness.watcher.poll(harness.workingDir) harness.watcher.poll(harness.repo)
// the implicit default build covers both branches; lint only selects main // the implicit default build covers both branches; lint only selects main
verify { harness.buildExecutor.startBuild("main", "commit-main", any(), BuildDefinition.DEFAULT) } verify { harness.buildExecutor.startBuild(harness.repo, "main", "commit-main", BuildDefinition.DEFAULT) }
verify { harness.buildExecutor.startBuild("feature/x", "commit-feat", any(), BuildDefinition.DEFAULT) } verify { harness.buildExecutor.startBuild(harness.repo, "feature/x", "commit-feat", BuildDefinition.DEFAULT) }
verify { harness.buildExecutor.startBuild("main", "commit-main", any(), "lint") } verify { harness.buildExecutor.startBuild(harness.repo, "main", "commit-main", "lint") }
verify(exactly = 0) { harness.buildExecutor.startBuild("feature/x", "commit-feat", any(), "lint") } verify(exactly = 0) { harness.buildExecutor.startBuild(harness.repo, "feature/x", "commit-feat", "lint") }
} }
test("builds.default with onPush false disables the implicit on-push build") { test("builds.default with onPush false disables the implicit on-push build") {
@@ -497,7 +497,7 @@ class WatcherTest : FunSpec() {
every { harness.gitService.hasNewCommits("main", any()) } returns true every { harness.gitService.hasNewCommits("main", any()) } returns true
every { harness.gitService.originHeadCommit("main", any()) } returns "commit-main" every { harness.gitService.originHeadCommit("main", any()) } returns "commit-main"
harness.watcher.poll(harness.workingDir) harness.watcher.poll(harness.repo)
harness.startedBuilds.shouldBeEmpty() harness.startedBuilds.shouldBeEmpty()
} }
@@ -509,9 +509,9 @@ class WatcherTest : FunSpec() {
every { harness.gitService.hasNewCommits("main", any()) } returns true every { harness.gitService.hasNewCommits("main", any()) } returns true
every { harness.gitService.originHeadCommit("main", any()) } returns "commit-main" every { harness.gitService.originHeadCommit("main", any()) } returns "commit-main"
harness.watcher.poll(harness.workingDir) harness.watcher.poll(harness.repo)
verify { harness.buildExecutor.startBuild("main", "commit-main", any(), BuildDefinition.DEFAULT) } verify { harness.buildExecutor.startBuild(harness.repo, "main", "commit-main", BuildDefinition.DEFAULT) }
} }
test("a build definition committed on a branch fires for that branch, without any entry in the primary config") { test("a build definition committed on a branch fires for that branch, without any entry in the primary config") {
@@ -528,10 +528,10 @@ class WatcherTest : FunSpec() {
every { harness.gitService.originHeadCommit("experiment", any()) } returns "commit-exp" every { harness.gitService.originHeadCommit("experiment", any()) } returns "commit-exp"
every { harness.gitService.originHeadCommit("main", any()) } returns "commit-main" every { harness.gitService.originHeadCommit("main", any()) } returns "commit-main"
harness.watcher.poll(harness.workingDir) harness.watcher.poll(harness.repo)
harness.startedBuilds shouldContainExactly listOf("experiment" to "commit-exp") harness.startedBuilds shouldContainExactly listOf("experiment" to "commit-exp")
verify { harness.buildExecutor.startBuild("experiment", "commit-exp", any(), "pitest") } verify { harness.buildExecutor.startBuild(harness.repo, "experiment", "commit-exp", "pitest") }
harness harness
.autoBuildState() .autoBuildState()
.isTriggered("experiment@pitest", LocalDate.parse("2026-07-07"), "11:00") .isTriggered("experiment@pitest", LocalDate.parse("2026-07-07"), "11:00")
@@ -550,7 +550,7 @@ class WatcherTest : FunSpec() {
every { harness.configLoader.loadWithBranchLayer(any(), "branch-yaml") } returns branchLayer every { harness.configLoader.loadWithBranchLayer(any(), "branch-yaml") } returns branchLayer
every { harness.gitService.originHeadCommit("experiment", any()) } returns "commit-exp" every { harness.gitService.originHeadCommit("experiment", any()) } returns "commit-exp"
harness.watcher.poll(harness.workingDir) harness.watcher.poll(harness.repo)
harness.startedBuilds shouldContainExactly listOf("experiment" to "commit-exp") harness.startedBuilds shouldContainExactly listOf("experiment" to "commit-exp")
} }
@@ -569,7 +569,7 @@ class WatcherTest : FunSpec() {
every { harness.configLoader.loadWithBranchLayer(any(), "branch-yaml") } returns branchLayer every { harness.configLoader.loadWithBranchLayer(any(), "branch-yaml") } returns branchLayer
every { harness.gitService.originHeadCommit(any(), any()) } returns "commit-any" every { harness.gitService.originHeadCommit(any(), any()) } returns "commit-any"
harness.watcher.poll(harness.workingDir) harness.watcher.poll(harness.repo)
// the definition selects main, but it is only known on experiment — so nothing is built // the definition selects main, but it is only known on experiment — so nothing is built
harness.startedBuilds.shouldBeEmpty() harness.startedBuilds.shouldBeEmpty()
@@ -580,12 +580,12 @@ class WatcherTest : FunSpec() {
every { harness.gitService.originBranches(any()) } returns listOf("main") every { harness.gitService.originBranches(any()) } returns listOf("main")
every { harness.gitService.originBranchHeads(any()) } returns mapOf("main" to "commit-1") every { harness.gitService.originBranchHeads(any()) } returns mapOf("main" to "commit-1")
harness.watcher.poll(harness.workingDir) harness.watcher.poll(harness.repo)
harness.watcher.poll(harness.workingDir) harness.watcher.poll(harness.repo)
verify(exactly = 1) { harness.gitService.showFileAtCommit("commit-1", Watcher.CONFIG_FILE, any()) } verify(exactly = 1) { harness.gitService.showFileAtCommit("commit-1", Watcher.CONFIG_FILE, any()) }
every { harness.gitService.originBranchHeads(any()) } returns mapOf("main" to "commit-2") every { harness.gitService.originBranchHeads(any()) } returns mapOf("main" to "commit-2")
harness.watcher.poll(harness.workingDir) harness.watcher.poll(harness.repo)
verify(exactly = 1) { harness.gitService.showFileAtCommit("commit-2", Watcher.CONFIG_FILE, any()) } verify(exactly = 1) { harness.gitService.showFileAtCommit("commit-2", Watcher.CONFIG_FILE, any()) }
} }
@@ -596,7 +596,7 @@ class WatcherTest : FunSpec() {
every { harness.gitService.originBranchHeads(any()) } returns mapOf("main" to "commit-1") every { harness.gitService.originBranchHeads(any()) } returns mapOf("main" to "commit-1")
every { harness.gitService.originHeadCommit("main", any()) } returns "commit-1" every { harness.gitService.originHeadCommit("main", any()) } returns "commit-1"
harness.watcher.poll(harness.workingDir) harness.watcher.poll(harness.repo)
harness.startedBuilds.shouldBeEmpty() harness.startedBuilds.shouldBeEmpty()
// the machine config gains a scheduled build while the branch stays where it is: // the machine config gains a scheduled build while the branch stays where it is:
@@ -608,9 +608,9 @@ class WatcherTest : FunSpec() {
every { harness.configLoader.load(any()) } returns edited every { harness.configLoader.load(any()) } returns edited
every { harness.configLoader.loadWithBranchLayer(any(), anyNullable()) } returns edited every { harness.configLoader.loadWithBranchLayer(any(), anyNullable()) } returns edited
harness.watcher.poll(harness.workingDir) harness.watcher.poll(harness.repo)
verify { harness.buildExecutor.startBuild("main", "commit-1", any(), "nightly") } verify { harness.buildExecutor.startBuild(harness.repo, "main", "commit-1", "nightly") }
} }
test("an unreadable branch config falls back to the primary definitions instead of failing the poll") { test("an unreadable branch config falls back to the primary definitions instead of failing the poll") {
@@ -624,13 +624,13 @@ class WatcherTest : FunSpec() {
RuntimeException("mapping problem") RuntimeException("mapping problem")
every { harness.gitService.originHeadCommit("main", any()) } returns "commit-main" every { harness.gitService.originHeadCommit("main", any()) } returns "commit-main"
harness.watcher.poll(harness.workingDir) harness.watcher.poll(harness.repo)
harness.watcher harness.watcher
.state() .state()
.lastPollError .lastPollError
.shouldBeNull() .shouldBeNull()
verify { harness.buildExecutor.startBuild("main", "commit-main", any(), BuildDefinition.DEFAULT) } verify { harness.buildExecutor.startBuild(harness.repo, "main", "commit-main", BuildDefinition.DEFAULT) }
} }
test("an auto-build slot stays untriggered while the branch is still building") { test("an auto-build slot stays untriggered while the branch is still building") {
@@ -639,7 +639,7 @@ class WatcherTest : FunSpec() {
every { harness.gitService.originBranches(any()) } returns listOf("main") every { harness.gitService.originBranches(any()) } returns listOf("main")
every { harness.gitService.originHeadCommit("main", any()) } returns "commit-abc" every { harness.gitService.originHeadCommit("main", any()) } returns "commit-abc"
harness.watcher.poll(harness.workingDir) harness.watcher.poll(harness.repo)
harness.startedBuilds.shouldBeEmpty() harness.startedBuilds.shouldBeEmpty()
harness.autoBuildState().isTriggered("main", LocalDate.parse("2026-07-07"), "11:00").shouldBeFalse() harness.autoBuildState().isTriggered("main", LocalDate.parse("2026-07-07"), "11:00").shouldBeFalse()
@@ -655,7 +655,7 @@ class WatcherTest : FunSpec() {
every { harness.gitService.originHeadCommit("feature/a", any()) } returns "commit-2" every { harness.gitService.originHeadCommit("feature/a", any()) } returns "commit-2"
every { harness.gitService.originHeadCommit("queued", any()) } returns "commit-3" every { harness.gitService.originHeadCommit("queued", any()) } returns "commit-3"
harness.watcher.recoverOnStartup(harness.workingDir) harness.watcher.recoverOnStartup(harness.repo)
harness.startedBuilds shouldContainExactlyInAnyOrder harness.startedBuilds shouldContainExactlyInAnyOrder
listOf("main" to "commit-1", "feature/a" to "commit-2", "queued" to "commit-3") listOf("main" to "commit-1", "feature/a" to "commit-2", "queued" to "commit-3")
@@ -671,7 +671,7 @@ class WatcherTest : FunSpec() {
harness.seed("main", BuildStatus.INTERRUPTED, commit = "commit-2") harness.seed("main", BuildStatus.INTERRUPTED, commit = "commit-2")
every { harness.gitService.originHeadCommit("main", any()) } returns "commit-2" every { harness.gitService.originHeadCommit("main", any()) } returns "commit-2"
harness.watcher.recoverOnStartup(harness.workingDir) harness.watcher.recoverOnStartup(harness.repo)
harness.startedBuilds shouldContainExactly listOf("main" to "commit-2") harness.startedBuilds shouldContainExactly listOf("main" to "commit-2")
} }
@@ -681,17 +681,17 @@ class WatcherTest : FunSpec() {
harness.seed("main", BuildStatus.INTERRUPTED, commit = "commit-1", build = "pitest") harness.seed("main", BuildStatus.INTERRUPTED, commit = "commit-1", build = "pitest")
every { harness.gitService.originHeadCommit("main", any()) } returns "commit-1" every { harness.gitService.originHeadCommit("main", any()) } returns "commit-1"
harness.watcher.recoverOnStartup(harness.workingDir) harness.watcher.recoverOnStartup(harness.repo)
// otherwise a restart mid-nightly-build would repeat it as a regular build in the wrong pool // otherwise a restart mid-nightly-build would repeat it as a regular build in the wrong pool
verify { harness.buildExecutor.startBuild("main", "commit-1", any(), "pitest") } verify { harness.buildExecutor.startBuild(harness.repo, "main", "commit-1", "pitest") }
} }
test("startup recovery closes out an orphaned PENDING build of a branch gone from origin") { test("startup recovery closes out an orphaned PENDING build of a branch gone from origin") {
val harness = Harness() val harness = Harness()
val orphan = harness.seed("gone", BuildStatus.PENDING, commit = "commit-1") val orphan = harness.seed("gone", BuildStatus.PENDING, commit = "commit-1")
harness.watcher.recoverOnStartup(harness.workingDir) harness.watcher.recoverOnStartup(harness.repo)
// PENDING is prune-immune; left as-is, the gone branch could never be pruned // PENDING is prune-immune; left as-is, the gone branch could never be pruned
harness.startedBuilds.shouldBeEmpty() harness.startedBuilds.shouldBeEmpty()
@@ -709,7 +709,7 @@ class WatcherTest : FunSpec() {
val removedWorktree = harness.worktreeDir("gone") val removedWorktree = harness.worktreeDir("gone")
every { harness.gitService.originBranches(any()) } returns listOf("main") every { harness.gitService.originBranches(any()) } returns listOf("main")
harness.watcher.poll(harness.workingDir) harness.watcher.poll(harness.repo)
harness.repository.history().map { it.branch } shouldContainExactly listOf("main") harness.repository.history().map { it.branch } shouldContainExactly listOf("main")
verify { verify {
@@ -728,7 +728,7 @@ class WatcherTest : FunSpec() {
keeping.seed("main", BuildStatus.FAILED, commit = "commit-2") keeping.seed("main", BuildStatus.FAILED, commit = "commit-2")
every { keeping.gitService.originBranches(any()) } returns listOf("main") every { keeping.gitService.originBranches(any()) } returns listOf("main")
keeping.watcher.poll(keeping.workingDir) keeping.watcher.poll(keeping.repo)
keeping.repository.history().map { it.status } shouldContainExactly keeping.repository.history().map { it.status } shouldContainExactly
listOf(BuildStatus.FAILED, BuildStatus.SUCCESS) listOf(BuildStatus.FAILED, BuildStatus.SUCCESS)
@@ -739,7 +739,7 @@ class WatcherTest : FunSpec() {
dropping.seed("main", BuildStatus.FAILED, commit = "commit-2") dropping.seed("main", BuildStatus.FAILED, commit = "commit-2")
every { dropping.gitService.originBranches(any()) } returns listOf("main") every { dropping.gitService.originBranches(any()) } returns listOf("main")
dropping.watcher.poll(dropping.workingDir) dropping.watcher.poll(dropping.repo)
dropping.repository.history().map { it.status } shouldContainExactly listOf(BuildStatus.FAILED) dropping.repository.history().map { it.status } shouldContainExactly listOf(BuildStatus.FAILED)
} }
@@ -751,7 +751,7 @@ class WatcherTest : FunSpec() {
harness.seed("main", BuildStatus.FAILED, commit = "commit-2") harness.seed("main", BuildStatus.FAILED, commit = "commit-2")
every { harness.gitService.originBranches(any()) } returns listOf("main") every { harness.gitService.originBranches(any()) } returns listOf("main")
harness.watcher.poll(harness.workingDir) harness.watcher.poll(harness.repo)
harness.repository.history().map { it.commit } shouldContainExactly listOf("commit-2") harness.repository.history().map { it.commit } shouldContainExactly listOf("commit-2")
} }
@@ -762,7 +762,7 @@ class WatcherTest : FunSpec() {
val busyWorktree = harness.worktreeDir("busy") val busyWorktree = harness.worktreeDir("busy")
every { harness.gitService.originBranches(any()) } returns listOf("busy") every { harness.gitService.originBranches(any()) } returns listOf("busy")
harness.watcher.poll(harness.workingDir) harness.watcher.poll(harness.repo)
Files.exists(busyWorktree).shouldBeTrue() Files.exists(busyWorktree).shouldBeTrue()
} }
@@ -772,14 +772,14 @@ class WatcherTest : FunSpec() {
val fetches = CountDownLatch(2) val fetches = CountDownLatch(2)
every { harness.gitService.fetchOrigin(any()) } answers { fetches.countDown() } every { harness.gitService.fetchOrigin(any()) } answers { fetches.countDown() }
harness.watcher.start(harness.workingDir) harness.watcher.start(harness.repo)
fetches.await(5, TimeUnit.SECONDS).shouldBeTrue() fetches.await(5, TimeUnit.SECONDS).shouldBeTrue()
harness.watcher harness.watcher
.state() .state()
.running .running
.shouldBeTrue() .shouldBeTrue()
shouldThrow<IllegalStateException> { harness.watcher.start(harness.workingDir) } shouldThrow<IllegalStateException> { harness.watcher.start(harness.repo) }
harness.watcher.stop() harness.watcher.stop()