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:
co-authored by
Claude Fable 5.1
parent
713edf77d6
commit
096cce3659
@@ -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.
|
||||
|
||||
## 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
|
||||
|
||||
`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`).
|
||||
|
||||
@@ -73,7 +77,7 @@ The runtime is selected per build behind the `BuildRunner` interface: `Dispatchi
|
||||
|
||||
## 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()`.
|
||||
|
||||
## System Metrics
|
||||
|
||||
@@ -30,13 +30,14 @@ IMPORTANT: Before designing or modifying code in any production package, load th
|
||||
|
||||
### 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
|
||||
|
||||
- `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.
|
||||
- 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`.
|
||||
- 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.
|
||||
|
||||
@@ -48,9 +48,10 @@ The pinning model is untouched: pinned keys still come from each repo's machine
|
||||
|
||||
### 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`.
|
||||
- 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.
|
||||
- Single-repo behavior, routes, and UI stay byte-identical; the full test suite is the acceptance gate.
|
||||
- ~~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.~~ — 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.~~ — 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
|
||||
|
||||
@@ -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.
|
||||
- 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
|
||||
|
||||
- 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 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.
|
||||
|
||||
@@ -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
|
||||
|
||||
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.Configuration
|
||||
|
||||
@Configuration
|
||||
class ArtifactsConfiguration {
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
/** The current repository's artifact store, for the code paths that still take the store 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
|
||||
|
||||
import de.hoennig.werkator.repo.RepoContext
|
||||
import org.springframework.context.annotation.Bean
|
||||
import org.springframework.context.annotation.Configuration
|
||||
import java.nio.file.Paths
|
||||
|
||||
@Configuration
|
||||
class BuildConfiguration {
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
/** The current repository's results, for the code paths that still take the 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.ConfigLoader
|
||||
import de.hoennig.werkator.gitea.GiteaClient
|
||||
import de.hoennig.werkator.repo.RepoContext
|
||||
import org.slf4j.LoggerFactory
|
||||
import org.springframework.context.ApplicationEventPublisher
|
||||
import org.springframework.context.event.ContextClosedEvent
|
||||
@@ -14,7 +15,6 @@ import java.io.InputStream
|
||||
import java.io.OutputStream
|
||||
import java.nio.file.Files
|
||||
import java.nio.file.Path
|
||||
import java.nio.file.Paths
|
||||
import java.nio.file.StandardOpenOption
|
||||
import java.time.Duration
|
||||
import java.time.Instant
|
||||
@@ -26,31 +26,30 @@ import java.util.concurrent.atomic.AtomicBoolean
|
||||
import kotlin.concurrent.thread
|
||||
|
||||
/**
|
||||
* Runs builds asynchronously: up to `executor.maxConcurrent` branches at the same time
|
||||
* (default 1), but never more than one build per branch. Each branch builds in its
|
||||
* own git worktree via [BranchWorkspaces], never in the primary checkout.
|
||||
* Every status transition is persisted via the [BuildResultRepository], published
|
||||
* to Gitea (non-fatal), and emitted as a [BuildStatusChangedEvent].
|
||||
* Runs builds asynchronously: up to `executor.maxConcurrent` builds at the same time
|
||||
* across all repositories (default 1), but never more than one build per branch of
|
||||
* a repository. Each branch builds in its own git worktree via [BranchWorkspaces],
|
||||
* never in the primary checkout. Every status transition is persisted in the
|
||||
* build's [RepoContext.results], published to Gitea (non-fatal), and emitted as a
|
||||
* [BuildStatusChangedEvent].
|
||||
*/
|
||||
@Service
|
||||
class BuildExecutor(
|
||||
private val repository: BuildResultRepository,
|
||||
private val configLoader: ConfigLoader,
|
||||
private val giteaClient: GiteaClient,
|
||||
private val buildRunner: BuildRunner,
|
||||
private val workspaces: BranchWorkspaces,
|
||||
private val artifactStore: ArtifactStore,
|
||||
private val eventPublisher: ApplicationEventPublisher,
|
||||
) {
|
||||
private val log = LoggerFactory.getLogger(BuildExecutor::class.java)
|
||||
|
||||
/** One serial worker per branch enforces at most one build per branch. */
|
||||
private val branchWorkers = ConcurrentHashMap<String, ExecutorService>()
|
||||
/** One serial worker per (repository, branch) enforces at most one build per branch of a repository. */
|
||||
private val branchWorkers = ConcurrentHashMap<Pair<RepoContext, String>, ExecutorService>()
|
||||
|
||||
/** All accepted, not yet finished builds by artifact key — queued and running. */
|
||||
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
|
||||
private var slots: Semaphore? = null
|
||||
|
||||
@@ -61,9 +60,10 @@ class BuildExecutor(
|
||||
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;
|
||||
* 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
|
||||
* 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
|
||||
@@ -76,15 +76,16 @@ class BuildExecutor(
|
||||
* worktree, serialized with the branch's other builds.
|
||||
*/
|
||||
fun startBuild(
|
||||
repo: RepoContext,
|
||||
branch: String,
|
||||
commit: String,
|
||||
workingDir: Path = Paths.get("."),
|
||||
build: String = BuildDefinition.DEFAULT,
|
||||
): RunningBuild {
|
||||
val name = BuildDefinition.poolName(branch, build)
|
||||
val duplicate =
|
||||
builds.values.firstOrNull {
|
||||
!it.cancelled.get() &&
|
||||
it.repo === repo &&
|
||||
it.runningBuild.name == name &&
|
||||
it.runningBuild.commit == commit
|
||||
}
|
||||
@@ -114,13 +115,13 @@ class BuildExecutor(
|
||||
duration = null,
|
||||
artifactKey = runningBuild.artifactKey,
|
||||
)
|
||||
repository.append(pending)
|
||||
repo.results.append(pending)
|
||||
eventPublisher.publishEvent(BuildStatusChangedEvent(pending))
|
||||
val activeBuild = ActiveBuild(runningBuild, workingDir)
|
||||
val activeBuild = ActiveBuild(runningBuild, repo)
|
||||
builds[runningBuild.artifactKey] = activeBuild
|
||||
publishGiteaStatus(activeBuild, BuildStatus.PENDING, duration = null)
|
||||
branchWorkers
|
||||
.computeIfAbsent(branch) { serialWorker(it) }
|
||||
.computeIfAbsent(repo to branch) { serialWorker(branch) }
|
||||
.submit { execute(activeBuild) }
|
||||
return runningBuild
|
||||
}
|
||||
@@ -171,7 +172,7 @@ class BuildExecutor(
|
||||
var finalStatus: BuildStatus? = BuildStatus.FAILED
|
||||
var workspace: Path? = null
|
||||
try {
|
||||
slot = slotsFor(build.workingDir)
|
||||
slot = slotsFor(build.repo.workingDir)
|
||||
slot.acquire()
|
||||
if (build.cancelled.get()) {
|
||||
finalStatus = BuildStatus.CANCELLED
|
||||
@@ -188,7 +189,7 @@ class BuildExecutor(
|
||||
workspaces.prepare(
|
||||
branch = build.runningBuild.branch,
|
||||
commit = build.runningBuild.commit,
|
||||
repoDir = build.workingDir,
|
||||
repoDir = build.repo.workingDir,
|
||||
)
|
||||
workspace = preparedWorkspace
|
||||
val exitCode = runBuildCommands(build, preparedWorkspace)
|
||||
@@ -220,7 +221,7 @@ class BuildExecutor(
|
||||
val duration = build.runningBuild.runningSince?.let { Duration.between(it, Instant.now()) }
|
||||
val result = transition(build, finalStatus, duration)
|
||||
try {
|
||||
artifactStore.persist(result, build.runningBuild.stagingDir, workspace)
|
||||
build.repo.artifactStore.persist(result, build.runningBuild.stagingDir, workspace)
|
||||
} catch (e: Exception) {
|
||||
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;
|
||||
* changing `executor.maxConcurrent` requires a restart.
|
||||
* The semaphore is sized once from the first build's config — the global cap is an
|
||||
* instance-level setting (ADR 0009) and does not vary by repository; changing
|
||||
* `executor.maxConcurrent` requires a restart.
|
||||
*/
|
||||
private fun slotsFor(workingDir: Path): Semaphore {
|
||||
slots?.let { return it }
|
||||
@@ -256,7 +258,7 @@ class BuildExecutor(
|
||||
build: ActiveBuild,
|
||||
workspace: Path,
|
||||
): Int {
|
||||
val branchConfig = buildConfig(build.runningBuild, build.workingDir, workspace)
|
||||
val branchConfig = buildConfig(build.runningBuild, build.repo.workingDir, workspace)
|
||||
val buildCommand = branchConfig.buildCommand
|
||||
val stagingDir = build.runningBuild.stagingDir
|
||||
Files.newOutputStream(stagingDir.resolve(branchConfig.stdoutLog)).use { stdoutLog ->
|
||||
@@ -293,7 +295,7 @@ class BuildExecutor(
|
||||
command = command,
|
||||
workingDir = workspace,
|
||||
environment = mapOf("branch" to build.runningBuild.branch),
|
||||
repoDir = build.workingDir,
|
||||
repoDir = build.repo.workingDir,
|
||||
branchConfig = branchConfig,
|
||||
onAuxProcess = { aux ->
|
||||
// preparation phases (e.g. a Docker image build) must die on cancellation
|
||||
@@ -363,7 +365,7 @@ class BuildExecutor(
|
||||
): BuildResult {
|
||||
val runningBuild = build.runningBuild
|
||||
val updated =
|
||||
repository.updateByArtifactKey(runningBuild.artifactKey) {
|
||||
build.repo.results.updateByArtifactKey(runningBuild.artifactKey) {
|
||||
it.copy(
|
||||
status = status,
|
||||
runningSince = runningBuild.runningSince ?: it.runningSince,
|
||||
@@ -378,7 +380,7 @@ class BuildExecutor(
|
||||
runningSince = runningBuild.runningSince,
|
||||
duration = duration,
|
||||
artifactKey = runningBuild.artifactKey,
|
||||
).also { repository.append(it) }
|
||||
).also { build.repo.results.append(it) }
|
||||
eventPublisher.publishEvent(BuildStatusChangedEvent(updated))
|
||||
publishGiteaStatus(build, status, duration)
|
||||
return updated
|
||||
@@ -395,7 +397,7 @@ class BuildExecutor(
|
||||
status = status,
|
||||
description = description(status, duration),
|
||||
targetUrl = null,
|
||||
workingDir = build.workingDir,
|
||||
workingDir = build.repo.workingDir,
|
||||
// from the primary config, not the worktree: statusContext is pinned, so a
|
||||
// branch cannot report under a check name it was not given
|
||||
context = statusContextOf(build),
|
||||
@@ -409,7 +411,7 @@ class BuildExecutor(
|
||||
private fun statusContextOf(build: ActiveBuild): String =
|
||||
try {
|
||||
configLoader
|
||||
.load(build.workingDir)
|
||||
.load(build.repo.workingDir)
|
||||
.buildSettings(build.runningBuild.branch, build.runningBuild.build)
|
||||
.statusContext
|
||||
} catch (e: Exception) {
|
||||
@@ -496,7 +498,7 @@ class BuildExecutor(
|
||||
|
||||
private class ActiveBuild(
|
||||
val runningBuild: RunningBuild,
|
||||
val workingDir: Path,
|
||||
val repo: RepoContext,
|
||||
) {
|
||||
val cancelled = AtomicBoolean(false)
|
||||
|
||||
|
||||
@@ -2,12 +2,12 @@ package de.hoennig.werkator.commands
|
||||
|
||||
import de.hoennig.werkator.build.BuildStatus
|
||||
import de.hoennig.werkator.git.GitService
|
||||
import de.hoennig.werkator.repo.RepoContext
|
||||
import org.springframework.stereotype.Component
|
||||
import picocli.CommandLine.Command
|
||||
import picocli.CommandLine.ExitCode
|
||||
import picocli.CommandLine.Parameters
|
||||
import java.nio.file.Path
|
||||
import java.nio.file.Paths
|
||||
import java.util.concurrent.Callable
|
||||
|
||||
/**
|
||||
@@ -24,6 +24,8 @@ import java.util.concurrent.Callable
|
||||
class BuildCommand(
|
||||
private val gitService: GitService,
|
||||
private val consoleBuildRunner: ConsoleBuildRunner,
|
||||
/** The repository to build: the current working directory (a repo selector comes with the registry). */
|
||||
var repo: RepoContext,
|
||||
) : Callable<Int> {
|
||||
@Parameters(
|
||||
index = "0",
|
||||
@@ -33,7 +35,8 @@ class BuildCommand(
|
||||
)
|
||||
var branchFragment: String? = null
|
||||
|
||||
var workingDir: Path = Paths.get(".")
|
||||
private val workingDir: Path
|
||||
get() = repo.workingDir
|
||||
|
||||
override fun call(): Int {
|
||||
val branch: String
|
||||
@@ -47,7 +50,7 @@ class BuildCommand(
|
||||
return ExitCode.USAGE
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
package de.hoennig.werkator.commands
|
||||
|
||||
import de.hoennig.werkator.build.ArtifactStore
|
||||
import de.hoennig.werkator.build.BuildExecutor
|
||||
import de.hoennig.werkator.build.BuildResult
|
||||
import de.hoennig.werkator.build.BuildResultRepository
|
||||
import de.hoennig.werkator.build.BuildStatus
|
||||
import de.hoennig.werkator.build.RunningBuild
|
||||
import de.hoennig.werkator.config.BuildDefinition
|
||||
import de.hoennig.werkator.repo.RepoContext
|
||||
import de.hoennig.werkator.server.UiFormats
|
||||
import org.springframework.stereotype.Component
|
||||
import java.io.IOException
|
||||
@@ -14,7 +13,6 @@ import java.nio.channels.Channels
|
||||
import java.nio.channels.FileChannel
|
||||
import java.nio.file.Files
|
||||
import java.nio.file.Path
|
||||
import java.nio.file.Paths
|
||||
import java.nio.file.StandardOpenOption
|
||||
import java.time.Duration
|
||||
|
||||
@@ -26,31 +24,29 @@ import java.time.Duration
|
||||
@Component
|
||||
class ConsoleBuildRunner(
|
||||
private val buildExecutor: BuildExecutor,
|
||||
private val repository: BuildResultRepository,
|
||||
private val artifactStore: ArtifactStore,
|
||||
) {
|
||||
var pollIntervalMillis = 200L
|
||||
|
||||
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(
|
||||
repo: RepoContext,
|
||||
branch: String,
|
||||
commit: String,
|
||||
workingDir: Path = Paths.get("."),
|
||||
buildDefinition: String = BuildDefinition.DEFAULT,
|
||||
): BuildStatus {
|
||||
val build = buildExecutor.startBuild(branch, commit, workingDir, buildDefinition)
|
||||
val build = buildExecutor.startBuild(repo, branch, commit, buildDefinition)
|
||||
var printed = 0L
|
||||
var result: BuildResult? = null
|
||||
while (result?.status?.isTerminal != true) {
|
||||
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) {
|
||||
Thread.sleep(pollIntervalMillis)
|
||||
}
|
||||
}
|
||||
drainAfterBuild(build, printed)
|
||||
drainAfterBuild(repo, build, printed)
|
||||
val after = result.duration?.let { " after ${UiFormats.duration(it)}" } ?: ""
|
||||
println("build of branch $branch: ${result.status.name.lowercase()}$after")
|
||||
return result.status
|
||||
@@ -64,6 +60,7 @@ class ConsoleBuildRunner(
|
||||
* stored copy (which is byte-identical, so the offset carries over).
|
||||
*/
|
||||
private fun drainAfterBuild(
|
||||
repo: RepoContext,
|
||||
build: RunningBuild,
|
||||
alreadyPrinted: Long,
|
||||
) {
|
||||
@@ -79,7 +76,7 @@ class ConsoleBuildRunner(
|
||||
}
|
||||
Thread.sleep(pollIntervalMillis)
|
||||
}
|
||||
artifactStore.artifactDir(build.artifactKey)?.let { artifactDir ->
|
||||
repo.artifactStore.artifactDir(build.artifactKey)?.let { artifactDir ->
|
||||
printNewLogBytes(artifactDir.resolve(BuildExecutor.LIVE_LOG_FILE), printed)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,14 +1,13 @@
|
||||
package de.hoennig.werkator.commands
|
||||
|
||||
import de.hoennig.werkator.build.BuildResult
|
||||
import de.hoennig.werkator.build.BuildResultRepository
|
||||
import de.hoennig.werkator.build.BuildStatus
|
||||
import de.hoennig.werkator.git.GitService
|
||||
import de.hoennig.werkator.repo.RepoContext
|
||||
import org.springframework.stereotype.Component
|
||||
import picocli.CommandLine.Command
|
||||
import picocli.CommandLine.ExitCode
|
||||
import java.nio.file.Path
|
||||
import java.nio.file.Paths
|
||||
import java.util.concurrent.Callable
|
||||
|
||||
/**
|
||||
@@ -25,16 +24,18 @@ import java.util.concurrent.Callable
|
||||
)
|
||||
class RetryCommand(
|
||||
private val gitService: GitService,
|
||||
private val repository: BuildResultRepository,
|
||||
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> {
|
||||
var workingDir: Path = Paths.get(".")
|
||||
private val workingDir: Path
|
||||
get() = repo.workingDir
|
||||
|
||||
override fun call(): Int {
|
||||
val failed: List<BuildResult>
|
||||
try {
|
||||
fetchBestEffort()
|
||||
failed = repository.latestPerName().filter { it.status == BuildStatus.FAILED }
|
||||
failed = repo.results.latestPerName().filter { it.status == BuildStatus.FAILED }
|
||||
} catch (e: Exception) {
|
||||
System.err.println("error: ${e.message}")
|
||||
return ExitCode.USAGE
|
||||
@@ -52,7 +53,7 @@ class RetryCommand(
|
||||
}
|
||||
println("retrying build ${result.name} at commit ${commit.take(12)}")
|
||||
// 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) {
|
||||
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
|
||||
|
||||
import de.hoennig.werkator.build.BuildResultRepository
|
||||
import de.hoennig.werkator.git.GitService
|
||||
import de.hoennig.werkator.repo.RepoContext
|
||||
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:
|
||||
@@ -18,10 +16,10 @@ import java.nio.file.Paths
|
||||
@Component
|
||||
class BranchListing(
|
||||
private val gitService: GitService,
|
||||
private val repository: BuildResultRepository,
|
||||
) {
|
||||
fun branches(workingDir: Path = Paths.get(".")): List<BranchDto> {
|
||||
val heads = gitService.originBranchHeads(workingDir)
|
||||
fun branches(repo: RepoContext): List<BranchDto> {
|
||||
val repository = repo.results
|
||||
val heads = gitService.originBranchHeads(repo.workingDir)
|
||||
val namedResults = repository.latestPerName().filter { it.name != it.branch && it.branch in heads }
|
||||
val branchesWithNamedPool = namedResults.map { it.branch }.toSet()
|
||||
val branchRows =
|
||||
|
||||
@@ -7,6 +7,7 @@ import de.hoennig.werkator.build.BuildResultRepository
|
||||
import de.hoennig.werkator.build.BuildStatus
|
||||
import de.hoennig.werkator.config.BuildDefinition
|
||||
import de.hoennig.werkator.git.GitService
|
||||
import de.hoennig.werkator.repo.RepoContext
|
||||
import org.springframework.http.HttpStatus
|
||||
import org.springframework.http.ResponseEntity
|
||||
import org.springframework.web.bind.annotation.DeleteMapping
|
||||
@@ -20,7 +21,6 @@ import java.nio.ByteBuffer
|
||||
import java.nio.channels.FileChannel
|
||||
import java.nio.file.Files
|
||||
import java.nio.file.Path
|
||||
import java.nio.file.Paths
|
||||
import java.nio.file.StandardOpenOption
|
||||
|
||||
/**
|
||||
@@ -38,15 +38,17 @@ class BuildsApiController(
|
||||
private val controlTokens: ControlTokenService,
|
||||
private val gitService: GitService,
|
||||
private val branchListing: BranchListing,
|
||||
private val repo: RepoContext,
|
||||
) {
|
||||
var workingDir: Path = Paths.get(".")
|
||||
private val workingDir: Path
|
||||
get() = repo.workingDir
|
||||
|
||||
@GetMapping("/api/builds/latest")
|
||||
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`. */
|
||||
@GetMapping("/api/branches")
|
||||
fun branches(): List<BranchDto> = branchListing.branches(workingDir)
|
||||
fun branches(): List<BranchDto> = branchListing.branches(repo)
|
||||
|
||||
@GetMapping("/api/builds/history")
|
||||
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)
|
||||
val running =
|
||||
buildExecutor.startBuild(
|
||||
repo = repo,
|
||||
branch = branchName,
|
||||
commit = commit,
|
||||
build = latest?.build ?: BuildDefinition.DEFAULT,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package de.hoennig.werkator.server
|
||||
|
||||
import de.hoennig.werkator.repo.RepoContext
|
||||
import de.hoennig.werkator.watcher.Watcher
|
||||
import jakarta.annotation.PreDestroy
|
||||
import org.springframework.boot.context.event.ApplicationReadyEvent
|
||||
@@ -8,18 +9,19 @@ import org.springframework.context.event.EventListener
|
||||
import org.springframework.stereotype.Component
|
||||
|
||||
/**
|
||||
* Starts the watcher poll loop once the server context is ready and stops it on
|
||||
* shutdown. Only in the `server` profile — CLI commands and tests never start
|
||||
* the loop (see [Watcher]).
|
||||
* Starts the watcher poll loop over the served repository once the server context
|
||||
* is ready and stops it on shutdown. Only in the `server` profile — CLI commands
|
||||
* and tests never start the loop (see [Watcher]).
|
||||
*/
|
||||
@Component
|
||||
@Profile("server")
|
||||
class ServerWatcherLifecycle(
|
||||
private val watcher: Watcher,
|
||||
private val repo: RepoContext,
|
||||
) {
|
||||
@EventListener(ApplicationReadyEvent::class)
|
||||
fun onApplicationReady() {
|
||||
watcher.start()
|
||||
watcher.start(repo)
|
||||
}
|
||||
|
||||
@PreDestroy
|
||||
|
||||
@@ -9,6 +9,7 @@ import de.hoennig.werkator.config.ConfigFiles
|
||||
import de.hoennig.werkator.config.ConfigLoader
|
||||
import de.hoennig.werkator.git.GitService
|
||||
import de.hoennig.werkator.metrics.SystemMetricsCollector
|
||||
import de.hoennig.werkator.repo.RepoContext
|
||||
import jakarta.servlet.http.HttpServletRequest
|
||||
import org.springframework.beans.factory.ObjectProvider
|
||||
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.file.Files
|
||||
import java.nio.file.Path
|
||||
import java.nio.file.Paths
|
||||
import kotlin.io.path.name
|
||||
import kotlin.streams.asSequence
|
||||
|
||||
@@ -43,8 +43,10 @@ class UiController(
|
||||
private val branchListing: BranchListing,
|
||||
private val branchPermalinks: BranchPermalinks,
|
||||
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
|
||||
@@ -71,7 +73,7 @@ class UiController(
|
||||
@GetMapping("/branches")
|
||||
fun branches(model: Model): String {
|
||||
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("allowRestart", true)
|
||||
// a row here stands for a branch, not for a past run
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
package de.hoennig.werkator.watcher
|
||||
|
||||
import de.hoennig.werkator.build.ArtifactKeys
|
||||
import de.hoennig.werkator.build.ArtifactStore
|
||||
import de.hoennig.werkator.build.BuildExecutor
|
||||
import de.hoennig.werkator.build.BuildResultRepository
|
||||
import de.hoennig.werkator.build.BuildStatus
|
||||
import de.hoennig.werkator.build.GitWorktreeWorkspaces
|
||||
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.WerkatorConfig
|
||||
import de.hoennig.werkator.git.GitService
|
||||
import de.hoennig.werkator.repo.RepoContext
|
||||
import org.slf4j.LoggerFactory
|
||||
import org.springframework.stereotype.Service
|
||||
import java.nio.file.Files
|
||||
import java.nio.file.Path
|
||||
import java.nio.file.Paths
|
||||
import java.time.Clock
|
||||
import java.time.Instant
|
||||
import java.time.LocalDate
|
||||
@@ -39,8 +37,6 @@ import java.util.concurrent.TimeUnit
|
||||
class Watcher(
|
||||
private val gitService: GitService,
|
||||
private val buildExecutor: BuildExecutor,
|
||||
private val repository: BuildResultRepository,
|
||||
private val artifactStore: ArtifactStore,
|
||||
private val configLoader: ConfigLoader,
|
||||
private val clock: Clock,
|
||||
) {
|
||||
@@ -51,9 +47,22 @@ class Watcher(
|
||||
@Volatile
|
||||
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
|
||||
private var warnedDeprecatedAutoBuild = false
|
||||
var warnedDeprecatedAutoBuild = false
|
||||
|
||||
/**
|
||||
* The fetch failure last written to the log, so a lasting outage does not repeat the
|
||||
@@ -62,28 +71,27 @@ class Watcher(
|
||||
* loggable.
|
||||
*/
|
||||
@Volatile
|
||||
private var loggedFetchError: String? = null
|
||||
var loggedFetchError: String? = null
|
||||
|
||||
/** Build definitions per branch, cached by the branch's head commit — see [definitionsFor]. */
|
||||
private val branchDefinitions = ConcurrentHashMap<String, CachedDefinitions>()
|
||||
|
||||
fun state(): WatcherState = state
|
||||
val branchDefinitions = ConcurrentHashMap<String, CachedDefinitions>()
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs the startup recovery and schedules the poll loop with the fixed delay
|
||||
* `watcher.pollInterval`; the first poll runs immediately.
|
||||
*/
|
||||
@Synchronized
|
||||
fun start(workingDir: Path = Paths.get(".")) {
|
||||
fun start(repo: RepoContext) {
|
||||
check(scheduler == null) { "watcher is already running" }
|
||||
recoverOnStartup(workingDir)
|
||||
val interval = DurationParser.parse(configLoader.load(workingDir).watcher.pollInterval)
|
||||
recoverOnStartup(repo)
|
||||
val interval = DurationParser.parse(configLoader.load(repo.workingDir).watcher.pollInterval)
|
||||
scheduler =
|
||||
Executors
|
||||
.newSingleThreadScheduledExecutor { runnable ->
|
||||
Thread(runnable, "werkator-watcher").apply { isDaemon = true }
|
||||
}.also {
|
||||
it.scheduleWithFixedDelay({ pollSafely(workingDir) }, 0, interval.toMillis(), TimeUnit.MILLISECONDS)
|
||||
it.scheduleWithFixedDelay({ pollSafely(repo) }, 0, interval.toMillis(), TimeUnit.MILLISECONDS)
|
||||
}
|
||||
state = state.copy(running = true)
|
||||
}
|
||||
@@ -100,7 +108,9 @@ class Watcher(
|
||||
* superseded PENDING builds as INTERRUPTED, then re-enqueue every branch whose
|
||||
* 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 {
|
||||
gitService.fetchOrigin(workingDir)
|
||||
} catch (e: Exception) {
|
||||
@@ -130,7 +140,7 @@ class Watcher(
|
||||
}
|
||||
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
|
||||
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
|
||||
* worktrees of branches gone from origin.
|
||||
*/
|
||||
fun poll(workingDir: Path = Paths.get(".")) {
|
||||
fun poll(repo: RepoContext) {
|
||||
val startedAt = clock.instant()
|
||||
val workingDir = repo.workingDir
|
||||
val watch = watchOf(repo)
|
||||
try {
|
||||
gitService.fetchOrigin(workingDir)
|
||||
if (loggedFetchError != null) {
|
||||
if (watch.loggedFetchError != null) {
|
||||
log.info("fetching origin succeeded again")
|
||||
loggedFetchError = null
|
||||
watch.loggedFetchError = null
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
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)
|
||||
loggedFetchError = failure
|
||||
watch.loggedFetchError = failure
|
||||
}
|
||||
state = state.copy(lastPollAt = startedAt, lastFetchError = failure)
|
||||
return
|
||||
}
|
||||
val config = configLoader.load(workingDir)
|
||||
val originBranches = gitService.originBranches(workingDir)
|
||||
enqueueDueBranches(config, originBranches.toSet(), workingDir)
|
||||
enqueueDueBranches(repo, config, originBranches.toSet())
|
||||
if (config.watcher.fastForwardLocalRefs) {
|
||||
fastForwardLocalRefs(workingDir)
|
||||
}
|
||||
prune(config, originBranches, workingDir)
|
||||
prune(repo, config, originBranches)
|
||||
state =
|
||||
state.copy(
|
||||
lastPollAt = startedAt,
|
||||
lastFetchError = null,
|
||||
lastPollError = null,
|
||||
queuedBranches =
|
||||
repository
|
||||
repo.results
|
||||
.latestPerName()
|
||||
.filter { it.status == BuildStatus.PENDING || it.status == BuildStatus.RUNNING }
|
||||
.map { it.name },
|
||||
)
|
||||
}
|
||||
|
||||
private fun pollSafely(workingDir: Path) {
|
||||
private fun pollSafely(repo: RepoContext) {
|
||||
try {
|
||||
poll(workingDir)
|
||||
poll(repo)
|
||||
} catch (e: Exception) {
|
||||
log.error("poll cycle failed", e)
|
||||
state = state.copy(lastPollAt = clock.instant(), lastPollError = e.message ?: e.javaClass.simpleName)
|
||||
@@ -208,16 +220,17 @@ class Watcher(
|
||||
}
|
||||
|
||||
private fun enqueueDueBranches(
|
||||
repo: RepoContext,
|
||||
config: WerkatorConfig,
|
||||
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
|
||||
val pullRequestHeads = lazy { gitService.pullRequestHeads(workingDir) }
|
||||
// one for-each-ref per cycle at most, and only when a definition filters by activeWithin
|
||||
val headCommitTimes = lazy { gitService.originBranchCommitTimes(workingDir) }
|
||||
val heads = gitService.originBranchHeads(workingDir)
|
||||
branchDefinitions.keys.retainAll(originBranches)
|
||||
watchOf(repo).branchDefinitions.keys.retainAll(originBranches)
|
||||
val changedLocal =
|
||||
gitService
|
||||
.localBranches(workingDir)
|
||||
@@ -226,15 +239,15 @@ class Watcher(
|
||||
gitService.newOriginBranches(DurationParser.parse(config.watcher.newBranchMaxAge), workingDir)
|
||||
val changed = (changedLocal + newOrigin).distinct()
|
||||
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) {
|
||||
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)
|
||||
enqueueDeprecatedAutoBuilds(config, originBranches, pullRequestHeads, workingDir)
|
||||
enqueueScheduledBuilds(repo, config, originBranches, heads, pullRequestHeads, headCommitTimes)
|
||||
enqueueDeprecatedAutoBuilds(repo, config, originBranches, pullRequestHeads)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -252,11 +265,13 @@ class Watcher(
|
||||
* instead of failing the poll cycle.
|
||||
*/
|
||||
private fun definitionsFor(
|
||||
repo: RepoContext,
|
||||
branch: String,
|
||||
headCommit: String?,
|
||||
workingDir: Path,
|
||||
primary: WerkatorConfig,
|
||||
): Map<String, BuildDefinition> {
|
||||
val workingDir = repo.workingDir
|
||||
val branchDefinitions = watchOf(repo).branchDefinitions
|
||||
val commit = headCommit ?: return primary.effectiveBuildDefinitions()
|
||||
branchDefinitions[branch]?.takeIf { it.commit == commit && it.primary == primary }?.let { return it.definitions }
|
||||
val definitions =
|
||||
@@ -305,14 +320,15 @@ class Watcher(
|
||||
* without pull-request refs.
|
||||
*/
|
||||
private fun startBuildIfDue(
|
||||
repo: RepoContext,
|
||||
branch: String,
|
||||
allowSameCommit: Boolean,
|
||||
config: WerkatorConfig,
|
||||
pullRequestHeads: Lazy<Set<String>>,
|
||||
workingDir: Path,
|
||||
build: String = BuildDefinition.DEFAULT,
|
||||
): 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) {
|
||||
return false
|
||||
}
|
||||
@@ -328,7 +344,7 @@ class Watcher(
|
||||
return false
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
@@ -338,20 +354,20 @@ class Watcher(
|
||||
* the point of a scheduled build.
|
||||
*/
|
||||
private fun enqueueScheduledBuilds(
|
||||
repo: RepoContext,
|
||||
config: WerkatorConfig,
|
||||
originBranches: Set<String>,
|
||||
heads: Map<String, String>,
|
||||
pullRequestHeads: Lazy<Set<String>>,
|
||||
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 today = LocalDate.ofInstant(now, ZoneOffset.UTC)
|
||||
val timeOfDay = LocalTime.ofInstant(now, ZoneOffset.UTC)
|
||||
for (branch in originBranches) {
|
||||
val scheduled =
|
||||
definitionsFor(branch, heads[branch], workingDir, config).filterValues {
|
||||
definitionsFor(repo, branch, heads[branch], config).filterValues {
|
||||
it.trigger.atTimes.isNotEmpty()
|
||||
}
|
||||
for ((buildName, definition) in scheduled) {
|
||||
@@ -363,7 +379,7 @@ class Watcher(
|
||||
if (autoBuildState.value.isTriggered(pool, today, slot)) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -376,10 +392,10 @@ class Watcher(
|
||||
* `builds` entry with `atTimes` and a single-branch selector would do.
|
||||
*/
|
||||
private fun enqueueDeprecatedAutoBuilds(
|
||||
repo: RepoContext,
|
||||
config: WerkatorConfig,
|
||||
originBranches: Set<String>,
|
||||
pullRequestHeads: Lazy<Set<String>>,
|
||||
workingDir: Path,
|
||||
) {
|
||||
val autoBuildBranches =
|
||||
config.branches.filter { (branch, branchConfig) ->
|
||||
@@ -388,14 +404,15 @@ class Watcher(
|
||||
if (autoBuildBranches.isEmpty()) {
|
||||
return
|
||||
}
|
||||
if (!warnedDeprecatedAutoBuild) {
|
||||
warnedDeprecatedAutoBuild = true
|
||||
val watch = watchOf(repo)
|
||||
if (!watch.warnedDeprecatedAutoBuild) {
|
||||
watch.warnedDeprecatedAutoBuild = true
|
||||
log.warn(
|
||||
"branches.*.autoBuild is deprecated; define a build with atTimes in the builds section instead (branches: {})",
|
||||
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 today = LocalDate.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)
|
||||
continue
|
||||
}
|
||||
if (startBuildIfDue(branch, allowSameCommit = true, config, pullRequestHeads, workingDir)) {
|
||||
if (startBuildIfDue(repo, branch, allowSameCommit = true, config, pullRequestHeads)) {
|
||||
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. */
|
||||
private fun prune(
|
||||
repo: RepoContext,
|
||||
config: WerkatorConfig,
|
||||
originBranches: List<String>,
|
||||
workingDir: Path,
|
||||
) {
|
||||
val retentionCutoff =
|
||||
config.artifacts.retentionMaxAge
|
||||
.takeIf { it.isNotBlank() }
|
||||
?.let { clock.instant().minus(DurationParser.parse(it)) }
|
||||
repository.prune(
|
||||
repo.results.prune(
|
||||
originBranches,
|
||||
config.artifacts.retentionPerBranch,
|
||||
config.artifacts.keepLatestGreen,
|
||||
retentionCutoff,
|
||||
)
|
||||
artifactStore.prune(repository.history())
|
||||
pruneWorktrees(originBranches, workingDir)
|
||||
repo.artifactStore.prune(repo.results.history())
|
||||
pruneWorktrees(repo, originBranches)
|
||||
}
|
||||
|
||||
private fun pruneWorktrees(
|
||||
repo: RepoContext,
|
||||
originBranches: List<String>,
|
||||
workingDir: Path,
|
||||
) {
|
||||
val workingDir = repo.workingDir
|
||||
val worktreesDir = workingDir.resolve(GitWorktreeWorkspaces.WORKTREES_DIR)
|
||||
if (!Files.isDirectory(worktreesDir)) {
|
||||
return
|
||||
@@ -445,7 +463,7 @@ class Watcher(
|
||||
val keep = originBranches.map { ArtifactKeys.branchKey(it) }.toMutableSet()
|
||||
// never delete under a build that is still queued or executing
|
||||
buildExecutor.currentBuilds().forEach { keep += ArtifactKeys.branchKey(it.branch) }
|
||||
repository
|
||||
repo.results
|
||||
.latestPerName()
|
||||
.filter { it.status == BuildStatus.PENDING || it.status == BuildStatus.RUNNING }
|
||||
.forEach { keep += ArtifactKeys.branchKey(it.branch) }
|
||||
|
||||
+3
-3
@@ -6,6 +6,7 @@ import de.hoennig.werkator.build.FileBuildResultRepository
|
||||
import de.hoennig.werkator.build.ProcessBuildRunner
|
||||
import de.hoennig.werkator.config.ConfigLoader
|
||||
import de.hoennig.werkator.gitea.GiteaClient
|
||||
import de.hoennig.werkator.repo.RepoContext
|
||||
import io.kotest.assertions.nondeterministic.eventually
|
||||
import io.kotest.core.spec.style.FunSpec
|
||||
import io.kotest.matchers.nulls.shouldNotBeNull
|
||||
@@ -37,18 +38,17 @@ class BuildExecutorArtifactIntegrationTest : FunSpec() {
|
||||
)
|
||||
val workspace = Files.createDirectories(workingDir.resolve("workspace"))
|
||||
val store = FileArtifactStore(ConfigLoader(), workingDir)
|
||||
val repo = RepoContext("test", workingDir, FileBuildResultRepository(workingDir.resolve("build-results.json")), store)
|
||||
val executor =
|
||||
BuildExecutor(
|
||||
repository = FileBuildResultRepository(workingDir.resolve("build-results.json")),
|
||||
configLoader = ConfigLoader(),
|
||||
giteaClient = mockk<GiteaClient>(relaxed = true),
|
||||
buildRunner = ProcessBuildRunner(),
|
||||
workspaces = BranchWorkspaces { _, _, _ -> workspace },
|
||||
artifactStore = store,
|
||||
eventPublisher = ApplicationEventPublisher { },
|
||||
)
|
||||
|
||||
val build = executor.startBuild("main", "abc123", workingDir)
|
||||
val build = executor.startBuild(repo, "main", "abc123")
|
||||
|
||||
lateinit var artifactDir: java.nio.file.Path
|
||||
eventually(30.seconds) {
|
||||
|
||||
@@ -2,6 +2,7 @@ package de.hoennig.werkator.build
|
||||
|
||||
import de.hoennig.werkator.config.ConfigLoader
|
||||
import de.hoennig.werkator.gitea.GiteaClient
|
||||
import de.hoennig.werkator.repo.RepoContext
|
||||
import io.kotest.assertions.nondeterministic.eventually
|
||||
import io.kotest.core.spec.style.FunSpec
|
||||
import io.kotest.matchers.booleans.shouldBeFalse
|
||||
@@ -48,14 +49,13 @@ class BuildExecutorTest : FunSpec() {
|
||||
Files.createDirectories(workingDir.resolve(workspaceSubdir))
|
||||
}
|
||||
}
|
||||
val repo = RepoContext("test", workingDir, repository, artifactStore)
|
||||
val executor =
|
||||
BuildExecutor(
|
||||
repository = repository,
|
||||
configLoader = ConfigLoader(),
|
||||
giteaClient = giteaClient,
|
||||
buildRunner = buildRunner,
|
||||
workspaces = workspaces,
|
||||
artifactStore = artifactStore,
|
||||
eventPublisher =
|
||||
ApplicationEventPublisher { event ->
|
||||
if (event is BuildStatusChangedEvent) {
|
||||
@@ -112,7 +112,7 @@ class BuildExecutorTest : FunSpec() {
|
||||
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)
|
||||
awaitIdle(h)
|
||||
@@ -141,7 +141,7 @@ class BuildExecutorTest : FunSpec() {
|
||||
test("build commands run in the workspace prepared for the branch") {
|
||||
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)
|
||||
awaitIdle(h)
|
||||
@@ -151,7 +151,7 @@ class BuildExecutorTest : FunSpec() {
|
||||
test("the repository reports RUNNING while the build sleeps") {
|
||||
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) {
|
||||
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
|
||||
val h = harness("test -f slow-done || { touch slow-done; sleep 2; }")
|
||||
|
||||
h.executor.startBuild("main", "abc123", h.workingDir)
|
||||
val second = h.executor.startBuild("main", "abc124", h.workingDir)
|
||||
h.executor.startBuild(h.repo, "main", "abc123")
|
||||
val second = h.executor.startBuild(h.repo, "main", "abc124")
|
||||
|
||||
eventually(30.seconds) {
|
||||
h.repository
|
||||
@@ -207,7 +207,7 @@ class BuildExecutorTest : FunSpec() {
|
||||
}
|
||||
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) {
|
||||
h.repository.latestFor("main")?.status shouldBe BuildStatus.RUNNING
|
||||
}
|
||||
@@ -232,7 +232,7 @@ class BuildExecutorTest : FunSpec() {
|
||||
""".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)
|
||||
awaitIdle(h)
|
||||
|
||||
@@ -250,7 +250,7 @@ class BuildExecutorTest : FunSpec() {
|
||||
h.repository.latestFor("main") shouldBe null
|
||||
|
||||
// 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)
|
||||
awaitIdle(h)
|
||||
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") {
|
||||
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)
|
||||
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") {
|
||||
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
|
||||
val duplicate = h.executor.startBuild("main", "abc123", h.workingDir)
|
||||
val duplicate = h.executor.startBuild(h.repo, "main", "abc123")
|
||||
duplicate.artifactKey shouldBe 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
|
||||
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
|
||||
|
||||
// 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
|
||||
|
||||
// a cancel-requested build no longer blocks re-queueing its commit
|
||||
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
|
||||
|
||||
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") {
|
||||
val h = harness("sleep 30")
|
||||
|
||||
val first = h.executor.startBuild("main", "abc123", h.workingDir)
|
||||
val second = h.executor.startBuild("main", "abc124", h.workingDir)
|
||||
val first = h.executor.startBuild(h.repo, "main", "abc123")
|
||||
val second = h.executor.startBuild(h.repo, "main", "abc124")
|
||||
eventually(30.seconds) {
|
||||
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") {
|
||||
val h = harness("exit 3")
|
||||
|
||||
h.executor.startBuild("main", "abc123", h.workingDir)
|
||||
h.executor.startBuild(h.repo, "main", "abc123")
|
||||
|
||||
awaitStatus(h, "main", BuildStatus.FAILED)
|
||||
awaitIdle(h)
|
||||
@@ -337,7 +337,7 @@ class BuildExecutorTest : FunSpec() {
|
||||
test("a failing clean command fails the build without running the build command") {
|
||||
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)
|
||||
awaitIdle(h)
|
||||
@@ -348,7 +348,7 @@ class BuildExecutorTest : FunSpec() {
|
||||
test("cancel kills a sleeping process tree and records CANCELLED") {
|
||||
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
|
||||
var children = emptyList<ProcessHandle>()
|
||||
@@ -376,7 +376,7 @@ class BuildExecutorTest : FunSpec() {
|
||||
test("shutdown kills an executing build and records INTERRUPTED, not FAILED") {
|
||||
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) {
|
||||
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") {
|
||||
val h = harness("sleep 30")
|
||||
|
||||
val first = h.executor.startBuild("main", "sha-1", h.workingDir)
|
||||
val second = h.executor.startBuild("main", "sha-2", h.workingDir)
|
||||
val first = h.executor.startBuild(h.repo, "main", "sha-1")
|
||||
val second = h.executor.startBuild(h.repo, "main", "sha-2")
|
||||
eventually(10.seconds) {
|
||||
h.repository
|
||||
.history()
|
||||
@@ -432,7 +432,7 @@ class BuildExecutorTest : FunSpec() {
|
||||
test("shutdown without any build in flight is a no-op") {
|
||||
val h = harness("echo ok")
|
||||
|
||||
h.executor.startBuild("main", "abc123", h.workingDir)
|
||||
h.executor.startBuild(h.repo, "main", "abc123")
|
||||
awaitStatus(h, "main", BuildStatus.SUCCESS)
|
||||
awaitIdle(h)
|
||||
|
||||
@@ -450,7 +450,7 @@ class BuildExecutorTest : FunSpec() {
|
||||
test("the live log grows while the build is still running") {
|
||||
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) {
|
||||
Files.readString(build.liveLogFile) shouldContain "one-main"
|
||||
@@ -467,7 +467,7 @@ class BuildExecutorTest : FunSpec() {
|
||||
h.giteaClient.publishStatus(any(), any(), any(), any(), any(), any())
|
||||
} throws RuntimeException("gitea down")
|
||||
|
||||
h.executor.startBuild("main", "abc123", h.workingDir)
|
||||
h.executor.startBuild(h.repo, "main", "abc123")
|
||||
|
||||
awaitStatus(h, "main", BuildStatus.SUCCESS)
|
||||
}
|
||||
@@ -488,8 +488,8 @@ class BuildExecutorTest : FunSpec() {
|
||||
""".trimIndent(),
|
||||
)
|
||||
|
||||
h.executor.startBuild("branch-a", "sha-a", h.workingDir)
|
||||
h.executor.startBuild("branch-b", "sha-b", h.workingDir)
|
||||
h.executor.startBuild(h.repo, "branch-a", "sha-a")
|
||||
h.executor.startBuild(h.repo, "branch-b", "sha-b")
|
||||
|
||||
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") {
|
||||
val h = harness("sleep 10", maxConcurrent = 2)
|
||||
|
||||
val buildA = h.executor.startBuild("branch-a", "sha-a", h.workingDir)
|
||||
val buildB = h.executor.startBuild("branch-b", "sha-b", h.workingDir)
|
||||
val buildA = h.executor.startBuild(h.repo, "branch-a", "sha-a")
|
||||
val buildB = h.executor.startBuild(h.repo, "branch-b", "sha-b")
|
||||
|
||||
eventually(10.seconds) {
|
||||
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") {
|
||||
val h = harness("sleep 1", maxConcurrent = 2)
|
||||
|
||||
val first = h.executor.startBuild("main", "sha-1", h.workingDir)
|
||||
val second = h.executor.startBuild("main", "sha-2", h.workingDir)
|
||||
val first = h.executor.startBuild(h.repo, "main", "sha-1")
|
||||
val second = h.executor.startBuild(h.repo, "main", "sha-2")
|
||||
|
||||
eventually(30.seconds) {
|
||||
h.repository
|
||||
@@ -539,8 +539,8 @@ class BuildExecutorTest : FunSpec() {
|
||||
test("cancel only affects the addressed build, other branches keep running") {
|
||||
val h = harness("sleep 10", maxConcurrent = 2)
|
||||
|
||||
val buildA = h.executor.startBuild("branch-a", "sha-a", h.workingDir)
|
||||
val buildB = h.executor.startBuild("branch-b", "sha-b", h.workingDir)
|
||||
val buildA = h.executor.startBuild(h.repo, "branch-a", "sha-a")
|
||||
val buildB = h.executor.startBuild(h.repo, "branch-b", "sha-b")
|
||||
eventually(10.seconds) {
|
||||
h.repository.latestFor("branch-a")?.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.git.GitService
|
||||
import de.hoennig.werkator.repo.RepoContext
|
||||
import io.kotest.core.spec.style.FunSpec
|
||||
import io.kotest.matchers.shouldBe
|
||||
import io.kotest.matchers.string.shouldContain
|
||||
@@ -18,11 +19,11 @@ class BuildCommandTest : FunSpec() {
|
||||
private val gitService = mockk<GitService>()
|
||||
private val consoleBuildRunner = mockk<ConsoleBuildRunner>()
|
||||
private val dir: Path = Paths.get(".")
|
||||
private val repo = RepoContext("test", dir, mockk(), mockk())
|
||||
|
||||
private fun command(fragment: String? = null) =
|
||||
BuildCommand(gitService, consoleBuildRunner).apply {
|
||||
BuildCommand(gitService, consoleBuildRunner, repo).apply {
|
||||
branchFragment = fragment
|
||||
workingDir = dir
|
||||
}
|
||||
|
||||
init {
|
||||
@@ -35,13 +36,13 @@ class BuildCommandTest : FunSpec() {
|
||||
every { gitService.currentBranch(dir) } returns "main"
|
||||
every { gitService.localHeadCommit("main", dir) } returns "local-head"
|
||||
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
|
||||
captureConsole { exitCode = command().call() }
|
||||
|
||||
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") {
|
||||
@@ -49,20 +50,20 @@ class BuildCommandTest : FunSpec() {
|
||||
every { gitService.localHeadCommit("main", dir) } returns "local-head"
|
||||
every { gitService.hasNewCommits("main", dir) } returns true
|
||||
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
|
||||
captureConsole { exitCode = command().call() }
|
||||
|
||||
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") {
|
||||
every { gitService.currentBranch(dir) } returns "main"
|
||||
every { gitService.localHeadCommit("main", dir) } returns "local-head"
|
||||
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
|
||||
captureConsole { exitCode = command().call() }
|
||||
@@ -75,13 +76,13 @@ class BuildCommandTest : FunSpec() {
|
||||
every { gitService.originBranches(dir) } returns listOf("main", "feature/x")
|
||||
every { gitService.localHeadCommit("feature/x", dir) } returns null
|
||||
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
|
||||
captureConsole { exitCode = command(fragment = "x").call() }
|
||||
|
||||
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") {
|
||||
@@ -126,7 +127,7 @@ class BuildCommandTest : FunSpec() {
|
||||
every { gitService.currentBranch(dir) } returns "main"
|
||||
every { gitService.localHeadCommit("main", dir) } returns "local-head"
|
||||
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
|
||||
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.BuildStatus
|
||||
import de.hoennig.werkator.build.RunningBuild
|
||||
import de.hoennig.werkator.repo.RepoContext
|
||||
import io.kotest.core.spec.style.FunSpec
|
||||
import io.kotest.matchers.shouldBe
|
||||
import io.kotest.matchers.string.shouldContain
|
||||
@@ -23,9 +24,10 @@ class ConsoleBuildRunnerTest : FunSpec() {
|
||||
private val artifactStore = mockk<ArtifactStore>()
|
||||
|
||||
private lateinit var tempDir: Path
|
||||
private lateinit var repo: RepoContext
|
||||
|
||||
private fun runner() =
|
||||
ConsoleBuildRunner(buildExecutor, repository, artifactStore).apply {
|
||||
ConsoleBuildRunner(buildExecutor).apply {
|
||||
pollIntervalMillis = 1
|
||||
persistTimeoutMillis = 100
|
||||
}
|
||||
@@ -56,6 +58,7 @@ class ConsoleBuildRunnerTest : FunSpec() {
|
||||
beforeEach {
|
||||
clearMocks(buildExecutor, repository, artifactStore)
|
||||
tempDir = Files.createTempDirectory("werkator-console-build-test")
|
||||
repo = RepoContext("test", tempDir, repository, artifactStore)
|
||||
}
|
||||
|
||||
afterEach {
|
||||
@@ -66,7 +69,7 @@ class ConsoleBuildRunnerTest : FunSpec() {
|
||||
val stagingDir = Files.createDirectory(tempDir.resolve("staging"))
|
||||
val build = runningBuild(stagingDir)
|
||||
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)
|
||||
every { repository.history() } answers {
|
||||
stagingDir.toFile().deleteRecursively()
|
||||
@@ -75,7 +78,7 @@ class ConsoleBuildRunnerTest : FunSpec() {
|
||||
every { artifactStore.artifactDir("main-key") } returns 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
|
||||
console.stdout shouldContain "compiling ...\ntests green\n"
|
||||
@@ -87,12 +90,12 @@ class ConsoleBuildRunnerTest : FunSpec() {
|
||||
val build = runningBuild(stagingDir)
|
||||
val persistedDir = Files.createDirectory(tempDir.resolve("persisted"))
|
||||
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 { artifactStore.artifactDir("main-key") } returns persistedDir
|
||||
|
||||
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
|
||||
console.stdout shouldContain "full build output"
|
||||
@@ -103,11 +106,11 @@ class ConsoleBuildRunnerTest : FunSpec() {
|
||||
val stagingDir = Files.createDirectory(tempDir.resolve("staging"))
|
||||
val build = runningBuild(stagingDir)
|
||||
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))
|
||||
|
||||
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
|
||||
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.BuildStatus
|
||||
import de.hoennig.werkator.git.GitService
|
||||
import de.hoennig.werkator.repo.RepoContext
|
||||
import io.kotest.core.spec.style.FunSpec
|
||||
import io.kotest.matchers.shouldBe
|
||||
import io.kotest.matchers.string.shouldContain
|
||||
@@ -22,8 +23,9 @@ class RetryCommandTest : FunSpec() {
|
||||
private val repository = mockk<BuildResultRepository>()
|
||||
private val consoleBuildRunner = mockk<ConsoleBuildRunner>()
|
||||
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(
|
||||
branch: String,
|
||||
@@ -52,21 +54,21 @@ class RetryCommandTest : FunSpec() {
|
||||
)
|
||||
every { gitService.originHeadCommit("main", dir) } returns "head-main"
|
||||
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
|
||||
captureConsole { exitCode = command().call() }
|
||||
|
||||
exitCode shouldBe 0
|
||||
verify { consoleBuildRunner.buildAndStream("main", "head-main", dir, "default") }
|
||||
verify { consoleBuildRunner.buildAndStream("feature/y", "head-y", dir, "default") }
|
||||
verify(exactly = 0) { consoleBuildRunner.buildAndStream("feature/ok", any(), dir, any()) }
|
||||
verify { consoleBuildRunner.buildAndStream(repo, "main", "head-main", "default") }
|
||||
verify { consoleBuildRunner.buildAndStream(repo, "feature/y", "head-y", "default") }
|
||||
verify(exactly = 0) { consoleBuildRunner.buildAndStream(repo, "feature/ok", any(), any()) }
|
||||
}
|
||||
|
||||
test("exits with code 1 when a retried build fails again") {
|
||||
every { repository.latestPerName() } returns listOf(result("main", BuildStatus.FAILED))
|
||||
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
|
||||
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.BuildStatus
|
||||
import de.hoennig.werkator.git.GitService
|
||||
import de.hoennig.werkator.repo.RepoContext
|
||||
import io.kotest.core.spec.style.FunSpec
|
||||
import io.kotest.matchers.shouldBe
|
||||
import io.mockk.every
|
||||
@@ -14,7 +15,15 @@ import java.time.Instant
|
||||
class BranchListingTest : FunSpec() {
|
||||
private val gitService = mockk<GitService>()
|
||||
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 =
|
||||
BuildResult(
|
||||
@@ -38,7 +47,7 @@ class BranchListingTest : FunSpec() {
|
||||
every { repository.latestFor(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"
|
||||
rows.map { it.name } shouldBe listOf("master@release", "idle")
|
||||
@@ -56,7 +65,7 @@ class BranchListingTest : FunSpec() {
|
||||
every { repository.latestFor(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")
|
||||
}
|
||||
|
||||
@@ -68,7 +77,7 @@ class BranchListingTest : FunSpec() {
|
||||
every { repository.latestFor("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].status shouldBe "success"
|
||||
@@ -90,7 +99,7 @@ class BranchListingTest : FunSpec() {
|
||||
every { repository.latestGreenFor("feature/x") } returns
|
||||
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].latestGreenUrl shouldBe null
|
||||
@@ -107,7 +116,7 @@ class BranchListingTest : FunSpec() {
|
||||
every { repository.latestGreenFor("develop") } returns null
|
||||
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[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.RunningBuild
|
||||
import de.hoennig.werkator.git.GitService
|
||||
import de.hoennig.werkator.repo.RepoContext
|
||||
import io.kotest.core.spec.style.FunSpec
|
||||
import io.mockk.clearMocks
|
||||
import io.mockk.every
|
||||
@@ -50,6 +51,9 @@ class BuildsApiControllerTest : FunSpec() {
|
||||
@MockkBean
|
||||
lateinit var branchListing: BranchListing
|
||||
|
||||
@MockkBean
|
||||
lateinit var repo: RepoContext
|
||||
|
||||
private val startedAt = Instant.parse("2026-07-07T10:00:00Z")
|
||||
|
||||
private val successResult =
|
||||
@@ -74,7 +78,8 @@ class BuildsApiControllerTest : FunSpec() {
|
||||
|
||||
init {
|
||||
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 { 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") {
|
||||
val liveLogFile = tempDir.resolve("restart.log")
|
||||
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")
|
||||
|
||||
mockMvc
|
||||
@@ -164,14 +169,14 @@ class BuildsApiControllerTest : FunSpec() {
|
||||
.andExpect(jsonPath("$.status").value("pending"))
|
||||
.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") {
|
||||
val liveLogFile = tempDir.resolve("named-restart.log")
|
||||
every { repository.latestFor("main@pitest") } returns
|
||||
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")
|
||||
|
||||
mockMvc
|
||||
@@ -183,14 +188,14 @@ class BuildsApiControllerTest : FunSpec() {
|
||||
.andExpect(jsonPath("$.name").value("main@pitest"))
|
||||
|
||||
// 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") {
|
||||
val liveLogFile = tempDir.resolve("head-restart.log")
|
||||
every { repository.latestFor("main") } returns successResult
|
||||
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
|
||||
.perform(
|
||||
@@ -201,15 +206,15 @@ class BuildsApiControllerTest : FunSpec() {
|
||||
).andExpect(status().isAccepted)
|
||||
|
||||
// the recorded commit is deliberately not used: a branches row stands for a branch
|
||||
verify { buildExecutor.startBuild("main", "newhead1") }
|
||||
verify(exactly = 0) { buildExecutor.startBuild("main", successResult.commit) }
|
||||
verify { buildExecutor.startBuild(repo, "main", "newhead1") }
|
||||
verify(exactly = 0) { buildExecutor.startBuild(repo, "main", successResult.commit) }
|
||||
}
|
||||
|
||||
test("restart with atOriginHead keeps the recorded build definition and its real branch") {
|
||||
val liveLogFile = tempDir.resolve("head-named.log")
|
||||
every { repository.latestFor("main@pitest") } returns successResult.copy(build = "pitest", name = "main@pitest")
|
||||
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")
|
||||
|
||||
mockMvc
|
||||
@@ -220,7 +225,7 @@ class BuildsApiControllerTest : FunSpec() {
|
||||
.header(BuildsApiController.TOKEN_HEADER, "secret"),
|
||||
).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") {
|
||||
@@ -242,7 +247,7 @@ class BuildsApiControllerTest : FunSpec() {
|
||||
val liveLogFile = tempDir.resolve("first-build.log")
|
||||
every { repository.latestFor("fresh") } returns null
|
||||
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")
|
||||
|
||||
mockMvc
|
||||
@@ -250,7 +255,7 @@ class BuildsApiControllerTest : FunSpec() {
|
||||
.andExpect(status().isAccepted)
|
||||
.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") {
|
||||
@@ -290,7 +295,7 @@ class BuildsApiControllerTest : FunSpec() {
|
||||
.header(BuildsApiController.TOKEN_HEADER, "wrong"),
|
||||
).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") {
|
||||
@@ -317,7 +322,7 @@ class BuildsApiControllerTest : FunSpec() {
|
||||
.perform(delete("/api/builds/some-key").param("token", "secret"))
|
||||
.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) { repository.delete(any()) }
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import de.hoennig.werkator.config.ConfigLoader
|
||||
import de.hoennig.werkator.config.WerkatorConfig
|
||||
import de.hoennig.werkator.git.GitService
|
||||
import de.hoennig.werkator.metrics.SystemMetricsCollector
|
||||
import de.hoennig.werkator.repo.RepoContext
|
||||
import io.kotest.core.spec.style.FunSpec
|
||||
import io.mockk.clearMocks
|
||||
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 java.nio.file.Files
|
||||
import java.nio.file.Path
|
||||
import java.nio.file.Paths
|
||||
import java.time.Duration
|
||||
import java.time.Instant
|
||||
|
||||
@@ -65,6 +67,9 @@ class PermanentBranchRoutesTest : FunSpec() {
|
||||
@MockkBean
|
||||
lateinit var branchPermalinks: BranchPermalinks
|
||||
|
||||
@MockkBean
|
||||
lateinit var repo: RepoContext
|
||||
|
||||
private val artifactDir: Path = Files.createTempDirectory("werkator-permanent-routes-test")
|
||||
|
||||
private val greenBuild =
|
||||
@@ -89,7 +94,9 @@ class PermanentBranchRoutesTest : FunSpec() {
|
||||
metricsCollector,
|
||||
branchListing,
|
||||
branchPermalinks,
|
||||
repo,
|
||||
)
|
||||
every { repo.workingDir } returns Paths.get(".")
|
||||
every { configLoader.load(any()) } returns WerkatorConfig()
|
||||
every { configLoader.loadWithBranchLayer(any(), anyNullable()) } returns WerkatorConfig()
|
||||
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.SystemMetrics
|
||||
import de.hoennig.werkator.metrics.SystemMetricsCollector
|
||||
import de.hoennig.werkator.repo.RepoContext
|
||||
import io.kotest.core.spec.style.FunSpec
|
||||
import io.kotest.matchers.shouldBe
|
||||
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 java.nio.file.Files
|
||||
import java.nio.file.Path
|
||||
import java.nio.file.Paths
|
||||
import java.time.Duration
|
||||
import java.time.Instant
|
||||
|
||||
@@ -73,6 +75,9 @@ class UiControllerTest : FunSpec() {
|
||||
@MockkBean
|
||||
lateinit var branchPermalinks: BranchPermalinks
|
||||
|
||||
@MockkBean
|
||||
lateinit var repo: RepoContext
|
||||
|
||||
private val startedAt = Instant.parse("2026-07-07T10:00:00Z")
|
||||
|
||||
private val emptySystemMetrics =
|
||||
@@ -113,7 +118,9 @@ class UiControllerTest : FunSpec() {
|
||||
metricsCollector,
|
||||
branchListing,
|
||||
branchPermalinks,
|
||||
repo,
|
||||
)
|
||||
every { repo.workingDir } returns Paths.get(".")
|
||||
every { configLoader.load(any()) } returns
|
||||
WerkatorConfig(
|
||||
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.WerkatorConfig
|
||||
import de.hoennig.werkator.git.GitService
|
||||
import de.hoennig.werkator.repo.RepoContext
|
||||
import io.kotest.assertions.throwables.shouldThrow
|
||||
import io.kotest.core.spec.style.FunSpec
|
||||
import io.kotest.matchers.booleans.shouldBeFalse
|
||||
@@ -62,12 +63,11 @@ class WatcherTest : FunSpec() {
|
||||
val artifactStore = mockk<ArtifactStore>()
|
||||
val startedBuilds = CopyOnWriteArrayList<Pair<String, String>>()
|
||||
val configLoader = mockk<ConfigLoader>()
|
||||
val repo = RepoContext("test", workingDir, repository, artifactStore)
|
||||
val watcher =
|
||||
Watcher(
|
||||
gitService = gitService,
|
||||
buildExecutor = buildExecutor,
|
||||
repository = repository,
|
||||
artifactStore = artifactStore,
|
||||
configLoader = configLoader,
|
||||
clock = Clock.fixed(noon, ZoneOffset.UTC),
|
||||
)
|
||||
@@ -91,8 +91,8 @@ class WatcherTest : FunSpec() {
|
||||
every { gitService.fastForwardLocalBranches(any()) } returns emptyList()
|
||||
every { buildExecutor.currentBuilds() } returns emptyList()
|
||||
every { buildExecutor.startBuild(any(), any(), any(), any()) } answers {
|
||||
val branch = firstArg<String>()
|
||||
val commit = secondArg<String>()
|
||||
val branch = secondArg<String>()
|
||||
val commit = thirdArg<String>()
|
||||
startedBuilds += branch to commit
|
||||
runningBuild(branch, commit)
|
||||
}
|
||||
@@ -159,7 +159,7 @@ class WatcherTest : FunSpec() {
|
||||
val harness = Harness()
|
||||
every { harness.gitService.fetchOrigin(any()) } throws RuntimeException("origin unreachable")
|
||||
|
||||
harness.watcher.poll(harness.workingDir)
|
||||
harness.watcher.poll(harness.repo)
|
||||
|
||||
harness.watcher
|
||||
.state()
|
||||
@@ -170,7 +170,7 @@ class WatcherTest : FunSpec() {
|
||||
verify(exactly = 0) { harness.artifactStore.prune(any()) }
|
||||
|
||||
every { harness.gitService.fetchOrigin(any()) } returns Unit
|
||||
harness.watcher.poll(harness.workingDir)
|
||||
harness.watcher.poll(harness.repo)
|
||||
|
||||
harness.watcher
|
||||
.state()
|
||||
@@ -183,19 +183,19 @@ class WatcherTest : FunSpec() {
|
||||
val logged = captureWatcherLog()
|
||||
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
|
||||
logged().filter { it.contains("fetching origin failed") } shouldHaveSize 1
|
||||
|
||||
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
|
||||
|
||||
// a different failure is a different message and is worth saying again
|
||||
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
|
||||
}
|
||||
@@ -209,7 +209,7 @@ class WatcherTest : FunSpec() {
|
||||
every { harness.gitService.originHeadCommit("main", any()) } returns "commit-main"
|
||||
every { harness.gitService.originHeadCommit("feature/new", any()) } returns "commit-feature"
|
||||
|
||||
harness.watcher.poll(harness.workingDir)
|
||||
harness.watcher.poll(harness.repo)
|
||||
|
||||
harness.startedBuilds shouldContainExactly
|
||||
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.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
|
||||
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.fastForwardLocalBranches(any()) } throws RuntimeException("ref locked")
|
||||
|
||||
harness.watcher.poll(harness.workingDir)
|
||||
harness.watcher.poll(harness.repo)
|
||||
|
||||
harness.watcher
|
||||
.state()
|
||||
@@ -251,7 +251,7 @@ class WatcherTest : FunSpec() {
|
||||
val harness = Harness(WerkatorConfig(watcher = WatcherConfig(fastForwardLocalRefs = false)))
|
||||
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()) }
|
||||
}
|
||||
@@ -264,7 +264,7 @@ class WatcherTest : FunSpec() {
|
||||
every { harness.gitService.hasNewCommits("main", any()) } returns true
|
||||
every { harness.gitService.originHeadCommit("main", any()) } returns "commit-new"
|
||||
|
||||
harness.watcher.poll(harness.workingDir)
|
||||
harness.watcher.poll(harness.repo)
|
||||
|
||||
harness.startedBuilds.shouldBeEmpty()
|
||||
}
|
||||
@@ -280,7 +280,7 @@ class WatcherTest : FunSpec() {
|
||||
every { harness.gitService.originHeadCommit("main", any()) } returns "commit-2"
|
||||
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.watcher.state().queuedBranches shouldContainExactly listOf("main")
|
||||
@@ -294,11 +294,11 @@ class WatcherTest : FunSpec() {
|
||||
every { harness.gitService.hasNewCommits("main", any()) } returns true
|
||||
every { harness.gitService.originHeadCommit("main", any()) } returns "commit-abc"
|
||||
|
||||
harness.watcher.poll(harness.workingDir)
|
||||
harness.watcher.poll(harness.repo)
|
||||
harness.startedBuilds.shouldBeEmpty()
|
||||
|
||||
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")
|
||||
}
|
||||
@@ -306,7 +306,7 @@ class WatcherTest : FunSpec() {
|
||||
test("poll filters new origin branches by the configured newBranchMaxAge") {
|
||||
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()) }
|
||||
}
|
||||
@@ -319,7 +319,7 @@ class WatcherTest : FunSpec() {
|
||||
every { harness.gitService.originHeadCommit("feature/no-pr", any()) } returns "commit-solo"
|
||||
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")
|
||||
}
|
||||
@@ -330,7 +330,7 @@ class WatcherTest : FunSpec() {
|
||||
every { harness.gitService.newOriginBranches(any(), any()) } returns listOf("feature/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")
|
||||
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.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")
|
||||
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.originHeadCommit("main", any()) } returns "commit-main"
|
||||
|
||||
harness.watcher.poll(harness.workingDir)
|
||||
harness.watcher.poll(harness.repo)
|
||||
|
||||
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.originHeadCommit("main", any()) } returns "commit-abc"
|
||||
|
||||
harness.watcher.poll(harness.workingDir)
|
||||
harness.watcher.poll(harness.repo)
|
||||
|
||||
harness.startedBuilds.shouldBeEmpty()
|
||||
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.originHeadCommit("main", any()) } returns "commit-abc"
|
||||
|
||||
harness.watcher.poll(harness.workingDir)
|
||||
harness.watcher.poll(harness.workingDir)
|
||||
harness.watcher.poll(harness.repo)
|
||||
harness.watcher.poll(harness.repo)
|
||||
|
||||
harness.startedBuilds shouldContainExactly listOf("main" to "commit-abc")
|
||||
// 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()
|
||||
}
|
||||
|
||||
@@ -434,13 +434,13 @@ class WatcherTest : FunSpec() {
|
||||
every { harness.gitService.originHeadCommit("main", any()) } returns "commit-abc"
|
||||
every { harness.gitService.originHeadCommit("release/1.x", any()) } returns "commit-rel"
|
||||
|
||||
harness.watcher.poll(harness.workingDir)
|
||||
harness.watcher.poll(harness.workingDir)
|
||||
harness.watcher.poll(harness.repo)
|
||||
harness.watcher.poll(harness.repo)
|
||||
|
||||
// glob selector: main and release/1.x fire once, feature/x is not selected
|
||||
harness.startedBuilds shouldContainExactlyInAnyOrder
|
||||
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()
|
||||
}
|
||||
|
||||
@@ -460,10 +460,10 @@ class WatcherTest : FunSpec() {
|
||||
"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")
|
||||
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") {
|
||||
@@ -480,13 +480,13 @@ class WatcherTest : FunSpec() {
|
||||
every { harness.gitService.originHeadCommit("main", any()) } returns "commit-main"
|
||||
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
|
||||
verify { harness.buildExecutor.startBuild("main", "commit-main", any(), BuildDefinition.DEFAULT) }
|
||||
verify { harness.buildExecutor.startBuild("feature/x", "commit-feat", any(), BuildDefinition.DEFAULT) }
|
||||
verify { harness.buildExecutor.startBuild("main", "commit-main", any(), "lint") }
|
||||
verify(exactly = 0) { harness.buildExecutor.startBuild("feature/x", "commit-feat", any(), "lint") }
|
||||
verify { harness.buildExecutor.startBuild(harness.repo, "main", "commit-main", BuildDefinition.DEFAULT) }
|
||||
verify { harness.buildExecutor.startBuild(harness.repo, "feature/x", "commit-feat", BuildDefinition.DEFAULT) }
|
||||
verify { harness.buildExecutor.startBuild(harness.repo, "main", "commit-main", "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") {
|
||||
@@ -497,7 +497,7 @@ class WatcherTest : FunSpec() {
|
||||
every { harness.gitService.hasNewCommits("main", any()) } returns true
|
||||
every { harness.gitService.originHeadCommit("main", any()) } returns "commit-main"
|
||||
|
||||
harness.watcher.poll(harness.workingDir)
|
||||
harness.watcher.poll(harness.repo)
|
||||
|
||||
harness.startedBuilds.shouldBeEmpty()
|
||||
}
|
||||
@@ -509,9 +509,9 @@ class WatcherTest : FunSpec() {
|
||||
every { harness.gitService.hasNewCommits("main", any()) } returns true
|
||||
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") {
|
||||
@@ -528,10 +528,10 @@ class WatcherTest : FunSpec() {
|
||||
every { harness.gitService.originHeadCommit("experiment", any()) } returns "commit-exp"
|
||||
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")
|
||||
verify { harness.buildExecutor.startBuild("experiment", "commit-exp", any(), "pitest") }
|
||||
verify { harness.buildExecutor.startBuild(harness.repo, "experiment", "commit-exp", "pitest") }
|
||||
harness
|
||||
.autoBuildState()
|
||||
.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.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")
|
||||
}
|
||||
@@ -569,7 +569,7 @@ class WatcherTest : FunSpec() {
|
||||
every { harness.configLoader.loadWithBranchLayer(any(), "branch-yaml") } returns branchLayer
|
||||
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
|
||||
harness.startedBuilds.shouldBeEmpty()
|
||||
@@ -580,12 +580,12 @@ class WatcherTest : FunSpec() {
|
||||
every { harness.gitService.originBranches(any()) } returns listOf("main")
|
||||
every { harness.gitService.originBranchHeads(any()) } returns mapOf("main" to "commit-1")
|
||||
|
||||
harness.watcher.poll(harness.workingDir)
|
||||
harness.watcher.poll(harness.workingDir)
|
||||
harness.watcher.poll(harness.repo)
|
||||
harness.watcher.poll(harness.repo)
|
||||
verify(exactly = 1) { harness.gitService.showFileAtCommit("commit-1", Watcher.CONFIG_FILE, any()) }
|
||||
|
||||
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()) }
|
||||
}
|
||||
@@ -596,7 +596,7 @@ class WatcherTest : FunSpec() {
|
||||
every { harness.gitService.originBranchHeads(any()) } returns mapOf("main" to "commit-1")
|
||||
every { harness.gitService.originHeadCommit("main", any()) } returns "commit-1"
|
||||
|
||||
harness.watcher.poll(harness.workingDir)
|
||||
harness.watcher.poll(harness.repo)
|
||||
harness.startedBuilds.shouldBeEmpty()
|
||||
|
||||
// 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.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") {
|
||||
@@ -624,13 +624,13 @@ class WatcherTest : FunSpec() {
|
||||
RuntimeException("mapping problem")
|
||||
every { harness.gitService.originHeadCommit("main", any()) } returns "commit-main"
|
||||
|
||||
harness.watcher.poll(harness.workingDir)
|
||||
harness.watcher.poll(harness.repo)
|
||||
|
||||
harness.watcher
|
||||
.state()
|
||||
.lastPollError
|
||||
.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") {
|
||||
@@ -639,7 +639,7 @@ class WatcherTest : FunSpec() {
|
||||
every { harness.gitService.originBranches(any()) } returns listOf("main")
|
||||
every { harness.gitService.originHeadCommit("main", any()) } returns "commit-abc"
|
||||
|
||||
harness.watcher.poll(harness.workingDir)
|
||||
harness.watcher.poll(harness.repo)
|
||||
|
||||
harness.startedBuilds.shouldBeEmpty()
|
||||
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("queued", any()) } returns "commit-3"
|
||||
|
||||
harness.watcher.recoverOnStartup(harness.workingDir)
|
||||
harness.watcher.recoverOnStartup(harness.repo)
|
||||
|
||||
harness.startedBuilds shouldContainExactlyInAnyOrder
|
||||
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")
|
||||
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")
|
||||
}
|
||||
@@ -681,17 +681,17 @@ class WatcherTest : FunSpec() {
|
||||
harness.seed("main", BuildStatus.INTERRUPTED, commit = "commit-1", build = "pitest")
|
||||
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
|
||||
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") {
|
||||
val harness = Harness()
|
||||
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
|
||||
harness.startedBuilds.shouldBeEmpty()
|
||||
@@ -709,7 +709,7 @@ class WatcherTest : FunSpec() {
|
||||
val removedWorktree = harness.worktreeDir("gone")
|
||||
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")
|
||||
verify {
|
||||
@@ -728,7 +728,7 @@ class WatcherTest : FunSpec() {
|
||||
keeping.seed("main", BuildStatus.FAILED, commit = "commit-2")
|
||||
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
|
||||
listOf(BuildStatus.FAILED, BuildStatus.SUCCESS)
|
||||
@@ -739,7 +739,7 @@ class WatcherTest : FunSpec() {
|
||||
dropping.seed("main", BuildStatus.FAILED, commit = "commit-2")
|
||||
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)
|
||||
}
|
||||
@@ -751,7 +751,7 @@ class WatcherTest : FunSpec() {
|
||||
harness.seed("main", BuildStatus.FAILED, commit = "commit-2")
|
||||
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")
|
||||
}
|
||||
@@ -762,7 +762,7 @@ class WatcherTest : FunSpec() {
|
||||
val busyWorktree = harness.worktreeDir("busy")
|
||||
every { harness.gitService.originBranches(any()) } returns listOf("busy")
|
||||
|
||||
harness.watcher.poll(harness.workingDir)
|
||||
harness.watcher.poll(harness.repo)
|
||||
|
||||
Files.exists(busyWorktree).shouldBeTrue()
|
||||
}
|
||||
@@ -772,14 +772,14 @@ class WatcherTest : FunSpec() {
|
||||
val fetches = CountDownLatch(2)
|
||||
every { harness.gitService.fetchOrigin(any()) } answers { fetches.countDown() }
|
||||
|
||||
harness.watcher.start(harness.workingDir)
|
||||
harness.watcher.start(harness.repo)
|
||||
|
||||
fetches.await(5, TimeUnit.SECONDS).shouldBeTrue()
|
||||
harness.watcher
|
||||
.state()
|
||||
.running
|
||||
.shouldBeTrue()
|
||||
shouldThrow<IllegalStateException> { harness.watcher.start(harness.workingDir) }
|
||||
shouldThrow<IllegalStateException> { harness.watcher.start(harness.repo) }
|
||||
|
||||
harness.watcher.stop()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user