step 22b: repo context (#11)

* Step 22 B: RepoContext over the current repository

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

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

* Step 22 B: the watcher polls a RepoContext

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

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

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

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

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

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

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

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

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

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

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Michael Hönnig
2026-09-02 08:49:21 +02:00
committed by GitHub
co-authored by Claude Fable 5.1
parent 713edf77d6
commit 096cce3659
29 changed files with 571 additions and 293 deletions
@@ -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,39 +47,51 @@ class Watcher(
@Volatile
private var state = WatcherState()
/** The branches.*.autoBuild deprecation is logged once per watcher instance, not once per poll. */
@Volatile
private var warnedDeprecatedAutoBuild = false
/**
* The fetch failure last written to the log, so a lasting outage does not repeat the
* same warning on every poll — one wrong token produced 297 identical lines before
* this. Null while the last fetch succeeded, which is also what makes the recovery
* loggable.
*/
@Volatile
private var loggedFetchError: String? = null
/** Build definitions per branch, cached by the branch's head commit — see [definitionsFor]. */
private val branchDefinitions = ConcurrentHashMap<String, CachedDefinitions>()
/** 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
var warnedDeprecatedAutoBuild = false
/**
* The fetch failure last written to the log, so a lasting outage does not repeat the
* same warning on every poll — one wrong token produced 297 identical lines before
* this. Null while the last fetch succeeded, which is also what makes the recovery
* loggable.
*/
@Volatile
var loggedFetchError: String? = null
/** Build definitions per branch, cached by the branch's head commit — see [definitionsFor]. */
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) }
@@ -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()