feat(build): every running build knows its repository — die laufenden Builds und das Worktree-Aufräumen unterscheiden Repositories

Der letzte Übertrag aus Sitzung C: `RunningBuild` trug kein Repository, der
Executor ist aber instanzweit. Zwei Folgen, beide gemessen und jetzt behoben:

- Das Worktree-Aufräumen schützte die Worktrees ALLER Repositories. Ein
  laufender Build eines anderen Repositories auf einem gleichnamigen Branch
  hielt hier einen Worktree fest, dessen Branch längst von origin weg war.
- Die Current-Builds-Ansicht und `/api/builds/current` zeigten die Builds
  aller Repositories, schlugen ihren Status aber in den Ergebnissen NUR
  dieses Repositories nach — ein fremder Build fiel auf RUNNING zurück und
  zeigte einen Zustand, den niemand aufgezeichnet hat. Dasselbe beim
  Live-Log: der Schlüssel eines fremden Builds wurde beantwortet.

`RunningBuild.repo` ist der `RepoContext` selbst, verglichen wird per
Referenz — der Kontext ist die Identität (ADR 0009), und der Executor hat ihn
in `startBuild` ohnehin zur Hand. Watcher, API und UI filtern damit auf ihr
eigenes Repository.

Drei neue Tests, Gegenprobe per Mutation gezogen: Nimmt man die drei Filter
wieder heraus, fallen genau diese drei und sonst keiner. 493 Tests grün,
ktlint sauber.

Architektur-Skill und Plan (docs/plan/22-multi-repo.md) nachgezogen — die
Aussage „RunningBuild carries no repository" stimmte nicht mehr. Was von
Sitzung D bleibt: die repo-bezogenen Routen und die Oberfläche, die weiterhin
nur `registry.current()` bedienen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
mhoennig
2026-09-03 12:36:07 +02:00
co-authored by Claude Opus 5
parent 6fba43c627
commit 6ca67a6a37
11 changed files with 84 additions and 9 deletions
@@ -34,6 +34,7 @@ class ConsoleBuildRunnerTest : FunSpec() {
private fun runningBuild(stagingDir: Path) =
RunningBuild(
repo = repo,
branch = "main",
commit = "0123456789abcdef",
artifactKey = "main-key",
@@ -12,6 +12,7 @@ import de.hoennig.werkator.repo.RepoContext
import io.kotest.core.spec.style.FunSpec
import io.mockk.clearMocks
import io.mockk.every
import io.mockk.mockk
import io.mockk.verify
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest
@@ -68,6 +69,7 @@ class BuildsApiControllerTest : FunSpec() {
private fun runningBuild(liveLogFile: Path) =
RunningBuild(
repo = repo,
branch = "main",
commit = successResult.commit,
artifactKey = "main-abc123-running",
@@ -133,6 +135,35 @@ class BuildsApiControllerTest : FunSpec() {
.andExpect(jsonPath("$[0].logSize").value(5))
}
test("current answers only the served repository's builds") {
val liveLogFile = Files.writeString(tempDir.resolve("mine.log"), "12345")
val mine = runningBuild(liveLogFile)
val foreign =
runningBuild(liveLogFile).copy(
repo = mockk<RepoContext>(),
artifactKey = "other-repo-running",
)
every { buildExecutor.currentBuilds() } returns listOf(mine, foreign)
every { repository.history() } returns
listOf(successResult.copy(status = BuildStatus.RUNNING, artifactKey = mine.artifactKey))
mockMvc
.perform(get("/api/builds/current"))
.andExpect(status().isOk)
.andExpect(jsonPath("$.length()").value(1))
.andExpect(jsonPath("$[0].artifactKey").value(mine.artifactKey))
}
test("current log of a build in another repository answers 404") {
val liveLogFile = Files.writeString(tempDir.resolve("foreign.log"), "hello world")
val foreign = runningBuild(liveLogFile).copy(repo = mockk<RepoContext>())
every { buildExecutor.currentBuilds() } returns listOf(foreign)
mockMvc
.perform(get("/api/builds/current/${foreign.artifactKey}/log"))
.andExpect(status().isNotFound)
}
test("current log answers the tail from the requested offset") {
val liveLogFile = Files.writeString(tempDir.resolve("tail.log"), "hello world")
val build = runningBuild(liveLogFile)
@@ -246,6 +246,7 @@ class UiControllerTest : FunSpec() {
test("current view renders a card per running build with cancel button and started-at attribute") {
val build =
RunningBuild(
repo = repo,
branch = "main",
commit = successResult.commit,
artifactKey = "main-abc123-running",
@@ -94,7 +94,7 @@ class WatcherTest : FunSpec() {
val branch = secondArg<String>()
val commit = thirdArg<String>()
startedBuilds += branch to commit
runningBuild(branch, commit)
runningBuild(repo, branch, commit)
}
every { artifactStore.prune(any()) } returns emptyList()
}
@@ -131,11 +131,13 @@ class WatcherTest : FunSpec() {
}
private fun runningBuild(
repo: RepoContext,
branch: String,
commit: String,
): RunningBuild {
val stagingDir = Files.createTempDirectory("werkator-watcher-staging")
return RunningBuild(
repo = repo,
branch = branch,
commit = commit,
artifactKey = ArtifactKeys.buildKey(branch, Instant.now()),
@@ -272,7 +274,7 @@ class WatcherTest : FunSpec() {
test("a poll cycle completes while a build is running and still enqueues other branches") {
val harness = Harness()
harness.seed("main", BuildStatus.RUNNING, commit = "commit-1")
every { harness.buildExecutor.currentBuilds() } returns listOf(runningBuild("main", "commit-1"))
every { harness.buildExecutor.currentBuilds() } returns listOf(runningBuild(harness.repo, "main", "commit-1"))
every { harness.gitService.originBranches(any()) } returns listOf("main", "feature/other")
every { harness.gitService.localBranches(any()) } returns listOf("main")
every { harness.gitService.hasNewCommits("main", any()) } returns true
@@ -767,6 +769,28 @@ class WatcherTest : FunSpec() {
Files.exists(busyWorktree).shouldBeTrue()
}
test("a running build of another repository does not keep this repository's worktree") {
val harness = Harness()
harness.seed("gone", BuildStatus.SUCCESS, commit = "commit-1")
val goneWorktree = harness.worktreeDir("gone")
val otherDir = Files.createTempDirectory("werkator-watcher-other-running")
val other =
RepoContext(
"other",
otherDir,
FileBuildResultRepository(otherDir.resolve(".git/werkator/build-results.json")),
harness.artifactStore,
)
// the other repository builds a branch of the same name — its build must not
// protect this repository's worktree, whose branch is gone from origin
every { harness.buildExecutor.currentBuilds() } returns listOf(runningBuild(other, "gone", "commit-other"))
every { harness.gitService.originBranches(any()) } returns emptyList()
harness.watcher.poll(harness.repo)
Files.exists(goneWorktree).shouldBeFalse()
}
test("one repository's unreachable origin neither stops nor silences the other") {
val harness = Harness()
val otherDir = Files.createTempDirectory("werkator-watcher-other")