From bbb4969fb9e080f441268d7a4af43235a97e2a4c Mon Sep 17 00:00:00 2001 From: mhoennig Date: Fri, 4 Sep 2026 18:19:10 +0200 Subject: [PATCH] Run follow-up builds after a green predecessor (PR#23, point 3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit FollowUpTrigger listens to build status transitions and, on a SUCCESS, enqueues every definition of that branch whose afterSuccessOf names the finished build — at the finished build's commit, not the branch's current head, so a deployment ships the commit that was tested. Every green run triggers, a repeated one again. The trigger is armed by Watcher.start() and disarmed by stop(), so a CLI build never enqueues into a JVM about to exit; the CLI names the follow-ups the server would run instead. The event now carries the repository the result belongs to, which the listener has to act on. Co-Authored-By: Claude Fable 5.1 --- .../hoennig/werkator/build/BuildExecutor.kt | 4 +- .../de/hoennig/werkator/build/RunningBuild.kt | 7 +- .../hoennig/werkator/commands/BuildCommand.kt | 27 +++ .../werkator/watcher/FollowUpTrigger.kt | 134 ++++++++++++++ .../de/hoennig/werkator/watcher/Watcher.kt | 5 + .../werkator/build/BuildExecutorTest.kt | 36 ++++ .../werkator/commands/BuildCommandTest.kt | 20 +- .../werkator/watcher/FollowUpTriggerTest.kt | 173 ++++++++++++++++++ .../hoennig/werkator/watcher/WatcherTest.kt | 15 ++ 9 files changed, 416 insertions(+), 5 deletions(-) create mode 100644 src/main/kotlin/de/hoennig/werkator/watcher/FollowUpTrigger.kt create mode 100644 src/test/kotlin/de/hoennig/werkator/watcher/FollowUpTriggerTest.kt diff --git a/src/main/kotlin/de/hoennig/werkator/build/BuildExecutor.kt b/src/main/kotlin/de/hoennig/werkator/build/BuildExecutor.kt index 0de0403..ef588ad 100644 --- a/src/main/kotlin/de/hoennig/werkator/build/BuildExecutor.kt +++ b/src/main/kotlin/de/hoennig/werkator/build/BuildExecutor.kt @@ -119,7 +119,7 @@ class BuildExecutor( artifactKey = runningBuild.artifactKey, ) repo.results.append(pending) - eventPublisher.publishEvent(BuildStatusChangedEvent(pending)) + eventPublisher.publishEvent(BuildStatusChangedEvent(pending, repo)) val activeBuild = ActiveBuild(runningBuild, repo) builds[runningBuild.artifactKey] = activeBuild publishGiteaStatus(activeBuild, BuildStatus.PENDING, duration = null) @@ -384,7 +384,7 @@ class BuildExecutor( duration = duration, artifactKey = runningBuild.artifactKey, ).also { build.repo.results.append(it) } - eventPublisher.publishEvent(BuildStatusChangedEvent(updated)) + eventPublisher.publishEvent(BuildStatusChangedEvent(updated, build.repo)) publishGiteaStatus(build, status, duration) return updated } diff --git a/src/main/kotlin/de/hoennig/werkator/build/RunningBuild.kt b/src/main/kotlin/de/hoennig/werkator/build/RunningBuild.kt index dc81a83..59b161c 100644 --- a/src/main/kotlin/de/hoennig/werkator/build/RunningBuild.kt +++ b/src/main/kotlin/de/hoennig/werkator/build/RunningBuild.kt @@ -35,7 +35,12 @@ data class RunningBuild( var runningSince: Instant? = null } -/** Published via Spring's `ApplicationEventPublisher` on every persisted status transition. */ +/** + * Published via Spring's `ApplicationEventPublisher` on every persisted status transition. + * Carries the repository because a [BuildResult] does not: a listener that reacts to the + * transition — the follow-up trigger — has to act on that repository. + */ data class BuildStatusChangedEvent( val result: BuildResult, + val repo: RepoContext, ) diff --git a/src/main/kotlin/de/hoennig/werkator/commands/BuildCommand.kt b/src/main/kotlin/de/hoennig/werkator/commands/BuildCommand.kt index 08a58e3..44c7647 100644 --- a/src/main/kotlin/de/hoennig/werkator/commands/BuildCommand.kt +++ b/src/main/kotlin/de/hoennig/werkator/commands/BuildCommand.kt @@ -1,9 +1,11 @@ package de.hoennig.werkator.commands 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 de.hoennig.werkator.repo.RepoRegistry +import de.hoennig.werkator.watcher.FollowUpTrigger import org.springframework.stereotype.Component import picocli.CommandLine.Command import picocli.CommandLine.ExitCode @@ -27,6 +29,7 @@ class BuildCommand( private val gitService: GitService, private val consoleBuildRunner: ConsoleBuildRunner, private val registry: RepoRegistry, + private val followUpTrigger: FollowUpTrigger, ) : Callable { @Mixin var repoOption = RepoOption() @@ -58,9 +61,33 @@ class BuildCommand( } println("building branch $branch at commit ${commit.take(12)}") val status = consoleBuildRunner.buildAndStream(repo, branch, commit) + if (status == BuildStatus.SUCCESS) { + reportSkippedFollowUps(branch, commit) + } return if (status == BuildStatus.SUCCESS) ExitCode.OK else ExitCode.SOFTWARE } + /** + * A one-shot build ends with its process, so it never runs the follow-up builds the + * server would enqueue after a green run (PR#23) — it says which ones instead of + * leaving the operator to wonder why nothing was deployed. + */ + private fun reportSkippedFollowUps( + branch: String, + commit: String, + ) { + val followUps = + try { + followUpTrigger.followUpsOf(repo, branch, commit, BuildDefinition.DEFAULT) + } catch (e: Exception) { + System.err.println("warning: could not determine the follow-up builds (${e.message})") + return + } + if (followUps.isNotEmpty()) { + println("note: the server would now run the follow-up build(s) ${followUps.joinToString(", ")}; a CLI build does not") + } + } + /** A one-shot build should still work offline, from the last fetched origin state. */ private fun fetchBestEffort() { try { diff --git a/src/main/kotlin/de/hoennig/werkator/watcher/FollowUpTrigger.kt b/src/main/kotlin/de/hoennig/werkator/watcher/FollowUpTrigger.kt new file mode 100644 index 0000000..b4852c8 --- /dev/null +++ b/src/main/kotlin/de/hoennig/werkator/watcher/FollowUpTrigger.kt @@ -0,0 +1,134 @@ +package de.hoennig.werkator.watcher + +import de.hoennig.werkator.build.BuildExecutor +import de.hoennig.werkator.build.BuildResult +import de.hoennig.werkator.build.BuildStatus +import de.hoennig.werkator.build.BuildStatusChangedEvent +import de.hoennig.werkator.config.BuildDefinition +import de.hoennig.werkator.config.ConfigFiles +import de.hoennig.werkator.config.ConfigLoader +import de.hoennig.werkator.git.GitService +import de.hoennig.werkator.repo.RepoContext +import org.slf4j.LoggerFactory +import org.springframework.context.event.EventListener +import org.springframework.stereotype.Component +import java.time.Clock +import java.util.concurrent.atomic.AtomicBoolean + +/** + * Runs the follow-up builds (PR#23): whenever a build ends with `SUCCESS`, every + * definition of that branch whose `trigger.afterSuccessOf` names the finished build — + * and whose selector selects the branch — is enqueued on the same branch at the *same + * commit*, never at the branch's current origin head, so a deployment always ships the + * commit that was tested. Every green run counts, whatever started it, and a repeated + * green run of the same commit triggers again: a run of a green build that is silently + * not deployed would be more confusing than a redundant deployment. + * + * The definitions are resolved with the branch's committed config at the finished + * build's commit — the same layering the watcher applies — so the follow-up's command + * comes with the repository while its trigger stays the host's (pinned by + * [ConfigLoader]). The pull-request gate is not consulted: the predecessor passed it for + * this very commit, and the host's selector is the follow-up's own gate. + * + * Armed by [Watcher.start] and disarmed by [Watcher.stop], so a CLI `build` — whose + * process ends with its build — never enqueues a follow-up into a JVM that is about to + * exit; the CLI names the follow-ups the server would have run instead. + */ +@Component +class FollowUpTrigger( + private val gitService: GitService, + private val configLoader: ConfigLoader, + private val buildExecutor: BuildExecutor, + private val clock: Clock, +) { + private val log = LoggerFactory.getLogger(FollowUpTrigger::class.java) + + private val armed = AtomicBoolean(false) + + fun arm() { + armed.set(true) + } + + fun disarm() { + armed.set(false) + } + + fun isArmed(): Boolean = armed.get() + + @EventListener + fun onBuildStatusChanged(event: BuildStatusChangedEvent) { + if (!armed.get() || event.result.status != BuildStatus.SUCCESS) { + return + } + val result = event.result + try { + for (name in followUpsOf(event.repo, result)) { + log.info( + "[{}] enqueueing follow-up build {} of branch {} at commit {}, after {}", + event.repo.name, + name, + result.branch, + result.commit, + result.build, + ) + buildExecutor.startBuild(event.repo, result.branch, result.commit, name) + } + } catch (e: Exception) { + log.error("[{}] could not enqueue the follow-ups of {} at {}", event.repo.name, result.name, result.commit, e) + } + } + + /** The names of the builds that follow a green [result], in the order of their definitions; nothing is enqueued. */ + fun followUpsOf( + repo: RepoContext, + result: BuildResult, + ): List = followUpsOf(repo, result.branch, result.commit, result.build) + + /** The names of the builds that follow a green run of [build] on [branch] at [commit]; nothing is enqueued. */ + fun followUpsOf( + repo: RepoContext, + branch: String, + commit: String, + build: String, + ): List { + val workingDir = repo.workingDir + val definitions = definitionsAt(repo, branch, commit) + val headCommittedAt = lazy { gitService.originBranchCommitTimes(workingDir)[branch] } + return definitions + .filter { (_, definition) -> definition.trigger.afterSuccessOf == build } + .filter { (_, definition) -> definition.trigger.selects(branch, { headCommittedAt.value }, clock.instant()) } + .keys + .toList() + } + + /** + * The branch's definitions at [commit] — not at its head, which may have moved on + * since the predecessor started. An unreadable branch config falls back to the + * primary definitions, like the watcher does. + */ + private fun definitionsAt( + repo: RepoContext, + branch: String, + commit: String, + ): Map { + val workingDir = repo.workingDir + return try { + configLoader + .loadWithBranchLayer( + workingDir, + ConfigFiles.readCommitted { gitService.showFileAtCommit(commit, it, workingDir) }, + branch, + ).effectiveBuildDefinitions() + } catch (e: Exception) { + log.warn( + "[{}] ignoring the committed {} of branch {} at {} for its follow-ups: {}", + repo.name, + ConfigFiles.COMMITTED, + branch, + commit, + e.message ?: e.javaClass.simpleName, + ) + configLoader.load(workingDir).effectiveBuildDefinitions() + } + } +} diff --git a/src/main/kotlin/de/hoennig/werkator/watcher/Watcher.kt b/src/main/kotlin/de/hoennig/werkator/watcher/Watcher.kt index 7f2566b..d09e797 100644 --- a/src/main/kotlin/de/hoennig/werkator/watcher/Watcher.kt +++ b/src/main/kotlin/de/hoennig/werkator/watcher/Watcher.kt @@ -41,6 +41,7 @@ class Watcher( private val buildExecutor: BuildExecutor, private val configLoader: ConfigLoader, private val clock: Clock, + private val followUpTrigger: FollowUpTrigger, ) { private val log = LoggerFactory.getLogger(Watcher::class.java) @@ -83,11 +84,14 @@ class Watcher( * Runs the startup recovery of every repository and schedules the poll loop with the * fixed delay `watcher.pollInterval` — one loop, one delay: the instance's setting, * which every repository's effective config carries; the first poll runs immediately. + * Arms the [FollowUpTrigger] first, so the recovery's re-enqueued builds get their + * follow-ups too. */ @Synchronized fun start(repos: List) { check(scheduler == null) { "watcher is already running" } require(repos.isNotEmpty()) { "no repository to watch" } + followUpTrigger.arm() repos.forEach { recoverSafely(it) } val interval = DurationParser.parse(configLoader.load(repos.first().workingDir).watcher.pollInterval) scheduler = @@ -111,6 +115,7 @@ class Watcher( @Synchronized fun stop() { + followUpTrigger.disarm() scheduler?.shutdownNow() scheduler = null state = state.copy(running = false) diff --git a/src/test/kotlin/de/hoennig/werkator/build/BuildExecutorTest.kt b/src/test/kotlin/de/hoennig/werkator/build/BuildExecutorTest.kt index cb6a0a9..435ebc4 100644 --- a/src/test/kotlin/de/hoennig/werkator/build/BuildExecutorTest.kt +++ b/src/test/kotlin/de/hoennig/werkator/build/BuildExecutorTest.kt @@ -292,6 +292,42 @@ class BuildExecutorTest : FunSpec() { .build shouldBe "default" } + test("a follow-up build runs in its branch's worktree after its predecessor") { + val h = + Harness( + """ + executor: + maxConcurrent: 2 + builds: + default: + trigger: + onPush: true + cleanCommand: "" + buildCommand: "sleep 1; echo built > output.txt" + deploy: + trigger: + afterSuccessOf: default + buildCommand: "cat output.txt" + """.trimIndent(), + ) + + h.executor.startBuild(h.repo, "main", "c1", "default") + h.executor.startBuild(h.repo, "main", "c1", "deploy") + + awaitStatus(h, "main@deploy", BuildStatus.SUCCESS) + awaitIdle(h) + // same branch, same commit: the same worktree, and the follow-up saw the predecessor's output + h.workspaceCalls shouldContainExactly listOf("main" to "c1", "main" to "c1") + val predecessor = h.repository.latestFor("main").shouldNotBeNull() + val followUp = h.repository.latestFor("main@deploy").shouldNotBeNull() + predecessor.status shouldBe BuildStatus.SUCCESS + followUp.runningSince + .shouldNotBeNull() + .isBefore(predecessor.runningSince.shouldNotBeNull()) + .shouldBeFalse() + Files.readString(h.workingDir.resolve("output.txt")).trim() shouldBe "built" + } + test("a build whose definition was removed from the config falls back to the branch's settings") { val h = harness(buildCommand = "echo regular-\$branch") diff --git a/src/test/kotlin/de/hoennig/werkator/commands/BuildCommandTest.kt b/src/test/kotlin/de/hoennig/werkator/commands/BuildCommandTest.kt index 0b4dc8e..9293a1f 100644 --- a/src/test/kotlin/de/hoennig/werkator/commands/BuildCommandTest.kt +++ b/src/test/kotlin/de/hoennig/werkator/commands/BuildCommandTest.kt @@ -4,6 +4,7 @@ import de.hoennig.werkator.build.BuildStatus import de.hoennig.werkator.git.GitService import de.hoennig.werkator.repo.RepoContext import de.hoennig.werkator.repo.RepoRegistry +import de.hoennig.werkator.watcher.FollowUpTrigger import io.kotest.core.spec.style.FunSpec import io.kotest.matchers.shouldBe import io.kotest.matchers.string.shouldContain @@ -22,16 +23,31 @@ class BuildCommandTest : FunSpec() { private val dir: Path = Paths.get(".") private val repo = RepoContext("test", dir, mockk(), mockk()) private val registry = mockk().also { every { it.current() } returns repo } + private val followUpTrigger = mockk() private fun command(fragment: String? = null) = - BuildCommand(gitService, consoleBuildRunner, registry).apply { + BuildCommand(gitService, consoleBuildRunner, registry, followUpTrigger).apply { branchFragment = fragment } init { beforeEach { - clearMocks(gitService, consoleBuildRunner) + clearMocks(gitService, consoleBuildRunner, followUpTrigger) justRun { gitService.fetchOrigin(dir) } + every { followUpTrigger.followUpsOf(any(), any(), any(), any()) } returns emptyList() + } + + test("a green CLI build names the follow-up builds the server would run, and runs none") { + every { gitService.currentBranch(dir) } returns "main" + every { gitService.localHeadCommit("main", dir) } returns "local-head" + every { gitService.hasNewCommits("main", dir) } returns false + every { consoleBuildRunner.buildAndStream(repo, "main", "local-head") } returns BuildStatus.SUCCESS + every { followUpTrigger.followUpsOf(repo, "main", "local-head", "default") } returns listOf("deploy") + + val console = captureConsole { command().call() } + + console.stdout.shouldContain("follow-up build(s) deploy") + verify(exactly = 1) { consoleBuildRunner.buildAndStream(repo, "main", "local-head") } } test("builds the current branch at its local head when no branch is given") { diff --git a/src/test/kotlin/de/hoennig/werkator/watcher/FollowUpTriggerTest.kt b/src/test/kotlin/de/hoennig/werkator/watcher/FollowUpTriggerTest.kt new file mode 100644 index 0000000..5b7e25a --- /dev/null +++ b/src/test/kotlin/de/hoennig/werkator/watcher/FollowUpTriggerTest.kt @@ -0,0 +1,173 @@ +package de.hoennig.werkator.watcher + +import de.hoennig.werkator.build.ArtifactKeys +import de.hoennig.werkator.build.BuildExecutor +import de.hoennig.werkator.build.BuildResult +import de.hoennig.werkator.build.BuildStatus +import de.hoennig.werkator.build.BuildStatusChangedEvent +import de.hoennig.werkator.build.RunningBuild +import de.hoennig.werkator.config.BuildDefinition +import de.hoennig.werkator.config.ConfigLoader +import de.hoennig.werkator.config.TriggerConfig +import de.hoennig.werkator.config.WerkatorConfig +import de.hoennig.werkator.git.GitService +import de.hoennig.werkator.repo.RepoContext +import io.kotest.core.spec.style.FunSpec +import io.kotest.matchers.collections.shouldBeEmpty +import io.kotest.matchers.collections.shouldContainExactly +import io.mockk.every +import io.mockk.mockk +import java.nio.file.Files +import java.time.Clock +import java.time.Instant +import java.time.ZoneOffset +import java.util.concurrent.CopyOnWriteArrayList + +class FollowUpTriggerTest : FunSpec() { + private val noon = Instant.parse("2026-09-04T12:00:00Z") + + /** Which build was started on which branch at which commit. */ + private data class Started( + val branch: String, + val commit: String, + val build: String, + ) + + private inner class Harness( + config: WerkatorConfig, + ) { + val workingDir = Files.createTempDirectory("werkator-followup-test") + val gitService = mockk() + val configLoader = mockk() + val buildExecutor = mockk() + val repo = RepoContext("test", workingDir, mockk(), mockk()) + val started = CopyOnWriteArrayList() + val trigger = FollowUpTrigger(gitService, configLoader, buildExecutor, Clock.fixed(noon, ZoneOffset.UTC)) + + init { + every { configLoader.load(any()) } returns config + every { configLoader.loadWithBranchLayer(any(), anyNullable(), anyNullable()) } returns config + every { gitService.showFileAtCommit(any(), any(), any()) } returns null + // the branch moved on since the predecessor started + every { gitService.originHeadCommit(any(), any()) } returns "c2" + every { gitService.originBranchCommitTimes(any()) } returns mapOf("main" to noon.minusSeconds(60)) + every { buildExecutor.startBuild(any(), any(), any(), any()) } answers { + val branch = secondArg() + val commit = thirdArg() + val build = arg(3) + started += Started(branch, commit, build) + val staging = Files.createTempDirectory("werkator-followup-staging") + RunningBuild( + repo = repo, + branch = branch, + build = build, + commit = commit, + artifactKey = ArtifactKeys.buildKey(BuildDefinition.poolName(branch, build), noon), + startedAt = noon, + stagingDir = staging, + liveLogFile = staging.resolve("build.log"), + ) + } + } + + fun finished( + build: String, + status: BuildStatus, + branch: String = "main", + commit: String = "c1", + ) { + val result = + BuildResult( + branch = branch, + build = build, + commit = commit, + status = status, + startedAt = noon, + artifactKey = ArtifactKeys.buildKey(BuildDefinition.poolName(branch, build), noon), + ) + trigger.onBuildStatusChanged(BuildStatusChangedEvent(result, repo)) + } + } + + private fun deployAfter( + predecessor: String, + branches: List = emptyList(), + ): WerkatorConfig = + WerkatorConfig( + buildDefinitions = + mapOf( + "frontend" to BuildDefinition(trigger = TriggerConfig(onPush = true)), + "backend" to BuildDefinition(trigger = TriggerConfig(onPush = true)), + "deploy" to + BuildDefinition( + trigger = TriggerConfig(afterSuccessOf = predecessor, branches = branches), + buildCommand = "scripts/deploy.sh", + ), + ), + ) + + init { + test("a green predecessor enqueues the follow-up at the predecessor's commit") { + val h = Harness(deployAfter("frontend", branches = listOf("main"))) + h.trigger.arm() + + h.finished("frontend", BuildStatus.SUCCESS, commit = "c1") + + // c1, not the origin head c2 the branch has moved on to + h.started shouldContainExactly listOf(Started("main", "c1", "deploy")) + } + + test("every green run of the predecessor triggers the follow-up again") { + val h = Harness(deployAfter("frontend")) + h.trigger.arm() + + h.finished("frontend", BuildStatus.SUCCESS, commit = "c1") + h.finished("frontend", BuildStatus.SUCCESS, commit = "c1") + + h.started shouldContainExactly + listOf( + Started("main", "c1", "deploy"), + Started("main", "c1", "deploy"), + ) + } + + test("only a SUCCESS of the named predecessor triggers") { + val h = Harness(deployAfter("frontend", branches = listOf("main"))) + h.trigger.arm() + + h.finished("frontend", BuildStatus.FAILED) + h.finished("frontend", BuildStatus.CANCELLED) + h.finished("frontend", BuildStatus.INTERRUPTED) + h.finished("frontend", BuildStatus.PENDING) + h.finished("frontend", BuildStatus.RUNNING) + h.finished("backend", BuildStatus.SUCCESS) + // a branch the host's selector does not name never runs the follow-up + h.finished("frontend", BuildStatus.SUCCESS, branch = "feature/x") + + h.started.shouldBeEmpty() + } + + test("the trigger listens only while the watcher runs") { + val h = Harness(deployAfter("frontend")) + + h.finished("frontend", BuildStatus.SUCCESS) + h.started.shouldBeEmpty() + + h.trigger.arm() + h.finished("frontend", BuildStatus.SUCCESS) + h.started shouldContainExactly listOf(Started("main", "c1", "deploy")) + + h.trigger.disarm() + h.finished("frontend", BuildStatus.SUCCESS) + h.started shouldContainExactly listOf(Started("main", "c1", "deploy")) + } + + test("followUpsOf names the follow-ups without enqueueing anything") { + val h = Harness(deployAfter("default")) + + h.trigger.followUpsOf(h.repo, "main", "c1", "default") shouldContainExactly listOf("deploy") + h.trigger.followUpsOf(h.repo, "main", "c1", "frontend").shouldBeEmpty() + h.started.shouldBeEmpty() + } + } +} diff --git a/src/test/kotlin/de/hoennig/werkator/watcher/WatcherTest.kt b/src/test/kotlin/de/hoennig/werkator/watcher/WatcherTest.kt index bb2b4c7..7489fa3 100644 --- a/src/test/kotlin/de/hoennig/werkator/watcher/WatcherTest.kt +++ b/src/test/kotlin/de/hoennig/werkator/watcher/WatcherTest.kt @@ -63,6 +63,7 @@ class WatcherTest : FunSpec() { val artifactStore = mockk() val startedBuilds = CopyOnWriteArrayList>() val configLoader = mockk() + val followUpTrigger = mockk(relaxed = true) val repo = RepoContext("test", workingDir, repository, artifactStore) val watcher = Watcher( @@ -70,6 +71,7 @@ class WatcherTest : FunSpec() { buildExecutor = buildExecutor, configLoader = configLoader, clock = Clock.fixed(noon, ZoneOffset.UTC), + followUpTrigger = followUpTrigger, ) private var seedCounter = 0L @@ -157,6 +159,19 @@ class WatcherTest : FunSpec() { ) init { + test("start arms the follow-up trigger before the recovery, stop disarms it") { + val harness = Harness() + + harness.watcher.start(listOf(harness.repo)) + try { + verify(exactly = 1) { harness.followUpTrigger.arm() } + verify(exactly = 0) { harness.followUpTrigger.disarm() } + } finally { + harness.watcher.stop() + } + verify(exactly = 1) { harness.followUpTrigger.disarm() } + } + test("a fetch failure is exposed in the state and only retried next cycle") { val harness = Harness() every { harness.gitService.fetchOrigin(any()) } throws RuntimeException("origin unreachable")