Let auto-build slots run their own build command under their own name

A branches.<name>.autoBuild.times entry is now either a plain HH:MM
string or an object with time, its own buildCommand, and a name, so a
nightly slot can run a fuller check than the on-commit builds. The
watcher passes the slot's command and name to the executor, persisted in
the build result — UI restarts, gittally retry, and the startup recovery
repeat a build with the command and name it originally ran under.

A named slot (e.g. master@nightly) gets its own pool: repository
grouping, retention count, branches-view row (sorted after its branch),
latest status, and permanent latest-green artifact link are keyed by the
build name, while origin lookups, gone-branch pruning, worktrees, and
Gitea links/statuses stay keyed by the real branch. Without a name,
slot builds share the branch's pool as before.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
mhoennig
2026-08-28 19:11:35 +02:00
co-authored by Claude Fable 5
parent f292badac1
commit 8aee3190ea
35 changed files with 702 additions and 158 deletions
@@ -219,6 +219,50 @@ class BuildExecutorTest : FunSpec() {
}
}
test("a build command override replaces the branch's build command and is recorded in the result") {
val h = harness(buildCommand = "echo regular-\$branch")
val nightly = h.executor.startBuild("main", "sha-1", h.workingDir, "echo nightly-\$branch")
awaitStatus(h, "main", BuildStatus.SUCCESS)
awaitIdle(h)
val stdoutLog = Files.readString(nightly.stagingDir.resolve("build.stdout.log"))
stdoutLog shouldContain "nightly-main"
stdoutLog shouldNotContain "regular-main"
Files.readString(nightly.liveLogFile) shouldContain "triggered by: auto-build slot"
h.repository
.latestFor("main")
.shouldNotBeNull()
.buildCommandOverride shouldBe "echo nightly-\$branch"
// the same branch without an override runs the regular command
val regular = h.executor.startBuild("main", "sha-2", h.workingDir)
awaitStatus(h, "main", BuildStatus.SUCCESS)
awaitIdle(h)
Files.readString(regular.stagingDir.resolve("build.stdout.log")) shouldContain "regular-main"
h.repository
.latestFor("main")
.shouldNotBeNull()
.buildCommandOverride shouldBe null
}
test("a named build is recorded under its name, keyed by the sanitized name") {
val h = harness(buildCommand = "echo regular-\$branch")
val build = h.executor.startBuild("main", "sha-1", h.workingDir, "echo nightly-\$branch", "main@nightly")
awaitStatus(h, "main@nightly", BuildStatus.SUCCESS)
awaitIdle(h)
val result = h.repository.latestFor("main@nightly").shouldNotBeNull()
result.branch shouldBe "main"
result.name shouldBe "main@nightly"
result.artifactKey shouldBe build.artifactKey
build.artifactKey shouldContain "main_nightly"
Files.readString(build.liveLogFile) shouldContain "build name: main@nightly"
// the branch's own pool stays empty — the named build does not shadow it
h.repository.latestFor("main") shouldBe null
}
test("startBuild returns the active build of the same branch and commit instead of stacking a duplicate") {
val h = harness("sleep 30")
@@ -228,6 +272,10 @@ class BuildExecutorTest : FunSpec() {
duplicate.artifactKey shouldBe first.artifactKey
h.repository.history().map { it.artifactKey } shouldContainExactly listOf(first.artifactKey)
// a build of the same commit with a command override runs a different command — not a duplicate
val nightly = h.executor.startBuild("main", "abc123", h.workingDir, "echo full-check")
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)
newerCommit.artifactKey shouldNotBe first.artifactKey
@@ -237,6 +285,7 @@ class BuildExecutorTest : FunSpec() {
val again = h.executor.startBuild("main", "abc123", h.workingDir)
again.artifactKey shouldNotBe first.artifactKey
h.executor.cancel(nightly.artifactKey).shouldBeTrue()
h.executor.cancel(newerCommit.artifactKey).shouldBeTrue()
h.executor.cancel(again.artifactKey).shouldBeTrue()
eventually(30.seconds) {
@@ -39,7 +39,7 @@ class FileBuildResultRepositoryTest : FunSpec() {
val repository = FileBuildResultRepository(newFile())
repository.history().shouldBeEmpty()
repository.latestPerBranch().shouldBeEmpty()
repository.latestPerName().shouldBeEmpty()
repository.latestFor("main").shouldBeNull()
}
@@ -99,19 +99,52 @@ class FileBuildResultRepositoryTest : FunSpec() {
repository.latestGreenFor("unknown").shouldBeNull()
}
test("latestPerBranch returns one entry per branch, newest first") {
test("latestPerName returns one entry per build name, newest first") {
val repository = FileBuildResultRepository(newFile())
repository.append(result(branch = "main", startedOffsetSeconds = 0))
repository.append(result(branch = "main", startedOffsetSeconds = 60))
repository.append(result(branch = "feature/x", startedOffsetSeconds = 120))
repository.latestPerBranch() shouldContainExactly
repository.latestPerName() shouldContainExactly
listOf(
result(branch = "feature/x", startedOffsetSeconds = 120),
result(branch = "main", startedOffsetSeconds = 60),
)
}
test("a named result forms its own pool for latest, green, supersession, and retention") {
val repository = FileBuildResultRepository(newFile())
repository.append(result(branch = "main", status = BuildStatus.SUCCESS, startedOffsetSeconds = 0))
repository.append(
result(branch = "main", status = BuildStatus.FAILED, startedOffsetSeconds = 10, artifactKey = "nightly-10")
.copy(name = "main@nightly"),
)
repository.append(result(branch = "main", status = BuildStatus.SUCCESS, startedOffsetSeconds = 20))
repository.latestFor("main")!!.artifactKey shouldBe "main-20"
repository.latestFor("main@nightly")!!.artifactKey shouldBe "nightly-10"
// the branch pool's green build does not leak into the nightly pool
repository.latestGreenFor("main@nightly").shouldBeNull()
repository.latestPerName().map { it.name } shouldContainExactlyInAnyOrder listOf("main", "main@nightly")
// retention counts per name: retention 1 keeps the nightly although the branch built more recently
val removed = repository.prune(listOf("main"), retentionPerBranch = 1)
removed.map { it.artifactKey } shouldContainExactly listOf("main-0")
repository.history().map { it.artifactKey } shouldContainExactlyInAnyOrder listOf("main-20", "nightly-10")
}
test("prune drops a named pool once its underlying branch is gone from origin") {
val repository = FileBuildResultRepository(newFile())
repository.append(
result(branch = "gone", status = BuildStatus.SUCCESS, startedOffsetSeconds = 0, artifactKey = "nightly-key")
.copy(name = "gone@nightly"),
)
val removed = repository.prune(listOf("main"), retentionPerBranch = 3)
removed.map { it.artifactKey } shouldContainExactly listOf("nightly-key")
}
test("updateLatest transforms only the newest entry of the branch") {
val repository = FileBuildResultRepository(newFile())
repository.append(result(branch = "main", status = BuildStatus.SUCCESS, startedOffsetSeconds = 0))
@@ -44,7 +44,7 @@ class RetryCommandTest : FunSpec() {
}
test("retries every branch whose latest build failed, but no others") {
every { repository.latestPerBranch() } returns
every { repository.latestPerName() } returns
listOf(
result("main", BuildStatus.FAILED),
result("feature/ok", BuildStatus.SUCCESS),
@@ -52,19 +52,19 @@ 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) } returns BuildStatus.SUCCESS
every { consoleBuildRunner.buildAndStream(any(), any(), dir, anyNullable(), any()) } returns BuildStatus.SUCCESS
var exitCode = -1
captureConsole { exitCode = command().call() }
exitCode shouldBe 0
verify { consoleBuildRunner.buildAndStream("main", "head-main", dir) }
verify { consoleBuildRunner.buildAndStream("feature/y", "head-y", dir) }
verify(exactly = 0) { consoleBuildRunner.buildAndStream("feature/ok", any(), dir) }
verify { consoleBuildRunner.buildAndStream("main", "head-main", dir, null, "main") }
verify { consoleBuildRunner.buildAndStream("feature/y", "head-y", dir, null, "feature/y") }
verify(exactly = 0) { consoleBuildRunner.buildAndStream("feature/ok", any(), dir, anyNullable(), any()) }
}
test("exits with code 1 when a retried build fails again") {
every { repository.latestPerBranch() } returns listOf(result("main", BuildStatus.FAILED))
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
@@ -75,7 +75,7 @@ class RetryCommandTest : FunSpec() {
}
test("skips failed branches that are gone from origin") {
every { repository.latestPerBranch() } returns listOf(result("gone", BuildStatus.FAILED))
every { repository.latestPerName() } returns listOf(result("gone", BuildStatus.FAILED))
every { gitService.originHeadCommit("gone", dir) } returns null
var exitCode = -1
@@ -87,7 +87,7 @@ class RetryCommandTest : FunSpec() {
}
test("prints a hint when there is nothing to retry") {
every { repository.latestPerBranch() } returns
every { repository.latestPerName() } returns
listOf(
result("main", BuildStatus.SUCCESS),
result("feature/x", BuildStatus.INTERRUPTED),
@@ -36,7 +36,7 @@ class StatusCommandTest : FunSpec() {
}
test("prints the latest build per branch as a table with short commits and legacy duration format") {
every { repository.latestPerBranch() } returns
every { repository.latestPerName() } returns
listOf(
result("main", BuildStatus.SUCCESS),
result("feature/x", BuildStatus.FAILED, duration = null),
@@ -75,7 +75,7 @@ class StatusCommandTest : FunSpec() {
}
test("prints a hint when no builds are recorded yet") {
every { repository.latestPerBranch() } returns emptyList()
every { repository.latestPerName() } returns emptyList()
var exitCode = -1
val console = captureConsole { exitCode = StatusCommand(repository).call() }
@@ -47,6 +47,39 @@ class ConfigLoaderTest : FunSpec() {
loader.load(dir).builds.maxConcurrent shouldBe 3
}
test("autoBuild.times accepts plain HH:MM entries and slot objects with their own build command, mixed") {
val dir = Files.createTempDirectory("gittally-test")
dir.resolve(".gittally.yml").toFile().writeText(
"""
branches:
main:
autoBuild:
enabled: true
times:
- "01:00"
- time: "04:00"
buildCommand: ./gradlew fullCheck
name: main@nightly
""".trimIndent(),
)
val times =
loader
.load(dir)
.branches
.getValue("main")
.autoBuild.times
times shouldBe
listOf(
AutoBuildSlot("01:00"),
AutoBuildSlot("04:00", "./gradlew fullCheck", "main@nightly"),
)
// config:print round-trip: a slot without its own command serializes back to the plain string
loader.toYaml(times) shouldBe
"- \"01:00\"\n- time: \"04:00\"\n buildCommand: \"./gradlew fullCheck\"\n name: \"main@nightly\"\n"
}
test("repo install config overrides project config for same keys") {
val dir = Files.createTempDirectory("gittally-test")
dir.resolve(".gittally.yml").toFile().writeText(
@@ -27,6 +27,10 @@ class BranchListingTest : FunSpec() {
)
init {
beforeEach {
every { repository.latestPerName() } returns emptyList()
}
test("orders main/master first, then flat names, then hierarchical names") {
every { gitService.originBranchHeads(any()) } returns
mapOf(
@@ -69,14 +73,36 @@ class BranchListingTest : FunSpec() {
test("a failed latest build carries no permanent URL — it belongs to the older green build") {
every { gitService.originBranchHeads(any()) } returns mapOf("feature/x" to "aaa")
every { repository.latestFor("feature/x") } returns
mainResult.copy(branch = "feature/x", status = BuildStatus.FAILED, artifactKey = "failed-key")
mainResult.copy(branch = "feature/x", name = "feature/x", status = BuildStatus.FAILED, artifactKey = "failed-key")
every { repository.latestGreenFor("feature/x") } returns
mainResult.copy(branch = "feature/x", artifactKey = "green-key")
mainResult.copy(branch = "feature/x", name = "feature/x", artifactKey = "green-key")
val branches = listing.branches()
branches[0].status shouldBe "failed"
branches[0].latestGreenUrl shouldBe null
}
test("a named slot pool gets its own row right after its branch") {
val nightly =
mainResult.copy(name = "main@nightly", status = BuildStatus.FAILED, artifactKey = "nightly-key")
every { gitService.originBranchHeads(any()) } returns mapOf("main" to "head", "develop" to "d")
every { repository.latestFor("main") } returns mainResult
every { repository.latestFor("develop") } returns null
every { repository.latestGreenFor("main") } returns mainResult
every { repository.latestGreenFor("main@nightly") } returns null
every { repository.latestGreenFor("develop") } returns null
every { repository.latestPerName() } returns listOf(mainResult, nightly)
val rows = listing.branches()
rows.map { it.name } shouldBe listOf("main", "main@nightly", "develop")
rows[1].branch shouldBe "main"
rows[1].status shouldBe "failed"
rows[1].artifactKey shouldBe "nightly-key"
// the branch row keeps its own status and permanent link, untouched by the nightly
rows[0].status shouldBe "success"
rows[0].latestGreenUrl shouldBe "/branches/main"
}
}
}
@@ -33,21 +33,21 @@ class BranchPermalinksTest : FunSpec() {
init {
test("resolves the hash-free permanent key to the branch's latest green build") {
every { repository.latestPerBranch() } returns listOf(result("feature/x"), result("main"))
every { repository.latestPerName() } returns listOf(result("feature/x"), result("main"))
every { repository.latestGreenFor("feature/x") } returns result("feature/x")
permalinks.latestGreenBuild("feature_x") shouldBe result("feature/x")
}
test("resolves the full branch key with hash suffix") {
every { repository.latestPerBranch() } returns listOf(result("feature/x"))
every { repository.latestPerName() } returns listOf(result("feature/x"))
every { repository.latestGreenFor("feature/x") } returns result("feature/x")
permalinks.latestGreenBuild(ArtifactKeys.branchKey("feature/x")) shouldBe result("feature/x")
}
test("an unknown branch key answers 404") {
every { repository.latestPerBranch() } returns listOf(result("main"))
every { repository.latestPerName() } returns listOf(result("main"))
val exception = shouldThrow<ResponseStatusException> { permalinks.latestGreenBuild("gone") }
@@ -55,7 +55,7 @@ class BranchPermalinksTest : FunSpec() {
}
test("a branch without a green build answers 404") {
every { repository.latestPerBranch() } returns listOf(result("main", status = BuildStatus.FAILED))
every { repository.latestPerName() } returns listOf(result("main", status = BuildStatus.FAILED))
every { repository.latestGreenFor("main") } returns null
val exception = shouldThrow<ResponseStatusException> { permalinks.latestGreenBuild("main") }
@@ -64,7 +64,7 @@ class BranchPermalinksTest : FunSpec() {
}
test("a permanent key matching several branches answers 409 and names the candidates") {
every { repository.latestPerBranch() } returns listOf(result("feature/x"), result("feature_x"))
every { repository.latestPerName() } returns listOf(result("feature/x"), result("feature_x"))
val exception = shouldThrow<ResponseStatusException> { permalinks.latestGreenBuild("feature_x") }
@@ -73,7 +73,7 @@ class BranchPermalinksTest : FunSpec() {
}
test("with ambiguous permanent keys the full branch key still resolves") {
every { repository.latestPerBranch() } returns listOf(result("feature/x"), result("feature_x"))
every { repository.latestPerName() } returns listOf(result("feature/x"), result("feature_x"))
every { repository.latestGreenFor("feature/x") } returns result("feature/x")
permalinks.latestGreenBuild(ArtifactKeys.branchKey("feature/x")) shouldBe result("feature/x")
@@ -82,5 +82,14 @@ class BranchPermalinksTest : FunSpec() {
test("permanentUrl uses the hash-free branch key") {
BranchPermalinks.permanentUrl("feature/x") shouldBe "/branches/feature_x"
}
test("resolves a named slot pool to its own latest green build") {
val nightly = result("main").copy(name = "main@nightly", artifactKey = "nightly-key")
every { repository.latestPerName() } returns listOf(result("main"), nightly)
every { repository.latestGreenFor("main@nightly") } returns nightly
// sanitized like any branch key: the '@' becomes '_' in the URL
permalinks.latestGreenBuild("main_nightly") shouldBe nightly
}
}
}
@@ -80,7 +80,7 @@ class BuildsApiControllerTest : FunSpec() {
}
test("latest answers one entry per branch with lowercase status and duration in seconds") {
every { repository.latestPerBranch() } returns listOf(successResult)
every { repository.latestPerName() } returns listOf(successResult)
mockMvc
.perform(get("/api/builds/latest"))
@@ -151,9 +151,9 @@ 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")
every { repository.latestFor("feature/topic") } returns successResult.copy(branch = "feature/topic", name = "feature/topic")
every { buildExecutor.startBuild("feature/topic", successResult.commit) } returns
runningBuild(liveLogFile).copy(branch = "feature/topic")
runningBuild(liveLogFile).copy(branch = "feature/topic", name = "feature/topic")
mockMvc
.perform(
@@ -167,12 +167,58 @@ class BuildsApiControllerTest : FunSpec() {
verify { buildExecutor.startBuild("feature/topic", successResult.commit) }
}
test("restart of an auto-slot build repeats its recorded build command") {
val liveLogFile = tempDir.resolve("auto-restart.log")
every { repository.latestFor("main") } returns successResult.copy(buildCommandOverride = "./gradlew fullCheck")
every { buildExecutor.startBuild("main", successResult.commit, buildCommandOverride = "./gradlew fullCheck") } returns
runningBuild(liveLogFile).copy(buildCommandOverride = "./gradlew fullCheck")
mockMvc
.perform(post("/api/builds/restart").param("branch", "main").header(BuildsApiController.TOKEN_HEADER, "secret"))
.andExpect(status().isAccepted)
.andExpect(jsonPath("$.status").value("pending"))
// so a restarted nightly build repeats its slot's command, not the regular one
verify { buildExecutor.startBuild("main", successResult.commit, buildCommandOverride = "./gradlew fullCheck") }
}
test("restart of a named slot build re-runs under its name on its real branch") {
val liveLogFile = tempDir.resolve("named-restart.log")
every { repository.latestFor("main@nightly") } returns
successResult.copy(name = "main@nightly", buildCommandOverride = "./gradlew fullCheck")
every {
buildExecutor.startBuild(
"main",
successResult.commit,
buildCommandOverride = "./gradlew fullCheck",
name = "main@nightly",
)
} returns runningBuild(liveLogFile).copy(name = "main@nightly")
mockMvc
.perform(
post("/api/builds/restart")
.param("branch", "main@nightly")
.header(BuildsApiController.TOKEN_HEADER, "secret"),
).andExpect(status().isAccepted)
.andExpect(jsonPath("$.name").value("main@nightly"))
verify {
buildExecutor.startBuild(
"main",
successResult.commit,
buildCommandOverride = "./gradlew fullCheck",
name = "main@nightly",
)
}
}
test("restart of a never-built branch enqueues its origin head commit") {
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
runningBuild(liveLogFile).copy(branch = "fresh")
runningBuild(liveLogFile).copy(branch = "fresh", name = "fresh")
mockMvc
.perform(post("/api/builds/restart").param("branch", "fresh").header(BuildsApiController.TOKEN_HEADER, "secret"))
@@ -117,7 +117,7 @@ class UiControllerTest : FunSpec() {
}
test("latest view renders the empty state, and the nav no longer offers the current view") {
every { repository.latestPerBranch() } returns emptyList()
every { repository.latestPerName() } returns emptyList()
mockMvc
.perform(get("/"))
@@ -129,7 +129,7 @@ class UiControllerTest : FunSpec() {
}
test("latest view renders rows with badge, Gitea links, artifact link, actions, and token") {
every { repository.latestPerBranch() } returns listOf(successResult)
every { repository.latestPerName() } returns listOf(successResult)
mockMvc
.perform(get("/"))
@@ -188,9 +188,15 @@ class UiControllerTest : FunSpec() {
test("history view renders mixed history without restart actions") {
every { repository.history() } returns
listOf(
successResult.copy(branch = "main", status = BuildStatus.RUNNING, duration = null, artifactKey = "run-key"),
successResult.copy(
branch = "main",
name = "main",
status = BuildStatus.RUNNING,
duration = null,
artifactKey = "run-key",
),
successResult,
successResult.copy(branch = "feature/x", status = BuildStatus.FAILED, artifactKey = "failed-key"),
successResult.copy(branch = "feature/x", name = "feature/x", status = BuildStatus.FAILED, artifactKey = "failed-key"),
)
mockMvc
@@ -479,7 +485,7 @@ class UiControllerTest : FunSpec() {
test("branch names with HTML metacharacters render escaped") {
val nasty = "feat/<script>alert('x')</script>"
every { repository.latestPerBranch() } returns listOf(successResult.copy(branch = nasty))
every { repository.latestPerName() } returns listOf(successResult.copy(branch = nasty, name = nasty))
mockMvc
.perform(get("/"))
@@ -1,5 +1,6 @@
package de.hoennig.gittally.watcher
import de.hoennig.gittally.config.AutoBuildSlot
import io.kotest.core.spec.style.FunSpec
import io.kotest.matchers.booleans.shouldBeFalse
import io.kotest.matchers.booleans.shouldBeTrue
@@ -13,19 +14,27 @@ import java.time.LocalTime
class AutoBuildStateTest : FunSpec() {
private fun stateFile(): Path = Files.createTempDirectory("gittally-autobuild-test").resolve("auto-builds.json")
private fun slots(vararg times: String) = times.map { AutoBuildSlot(it) }
init {
test("latestDueSlot picks the latest slot at or before now") {
val times = listOf("01:00", "11:00", "13:00")
val times = slots("01:00", "11:00", "13:00")
AutoBuildSlots.latestDueSlot(times, LocalTime.parse("00:59")).shouldBeNull()
AutoBuildSlots.latestDueSlot(times, LocalTime.parse("01:00")) shouldBe "01:00"
AutoBuildSlots.latestDueSlot(times, LocalTime.parse("12:00")) shouldBe "11:00"
AutoBuildSlots.latestDueSlot(times, LocalTime.parse("23:59")) shouldBe "13:00"
AutoBuildSlots.latestDueSlot(times, LocalTime.parse("01:00"))?.time shouldBe "01:00"
AutoBuildSlots.latestDueSlot(times, LocalTime.parse("12:00"))?.time shouldBe "11:00"
AutoBuildSlots.latestDueSlot(times, LocalTime.parse("23:59"))?.time shouldBe "13:00"
}
test("latestDueSlot answers the whole slot including its build command") {
val slot = AutoBuildSlot(time = "01:00", buildCommand = "./gradlew fullCheck")
AutoBuildSlots.latestDueSlot(listOf(slot), LocalTime.parse("02:00")) shouldBe slot
}
test("latestDueSlot skips invalid slots but keeps the valid ones") {
AutoBuildSlots.latestDueSlot(listOf("25:99", "nope", "02:00"), LocalTime.parse("12:00")) shouldBe "02:00"
AutoBuildSlots.latestDueSlot(listOf("25:99"), LocalTime.parse("12:00")).shouldBeNull()
AutoBuildSlots.latestDueSlot(slots("25:99", "nope", "02:00"), LocalTime.parse("12:00"))?.time shouldBe "02:00"
AutoBuildSlots.latestDueSlot(slots("25:99"), LocalTime.parse("12:00")).shouldBeNull()
}
test("latestDueSlot of an empty slot list is null") {
@@ -10,6 +10,7 @@ import de.hoennig.gittally.build.GitWorktreeWorkspaces
import de.hoennig.gittally.build.RunningBuild
import de.hoennig.gittally.config.ArtifactsConfig
import de.hoennig.gittally.config.AutoBuildConfig
import de.hoennig.gittally.config.AutoBuildSlot
import de.hoennig.gittally.config.BranchConfig
import de.hoennig.gittally.config.ConfigLoader
import de.hoennig.gittally.config.GitTallyConfig
@@ -78,7 +79,7 @@ class WatcherTest : FunSpec() {
every { gitService.worktreePrune(any()) } returns Unit
every { gitService.fastForwardLocalBranches(any()) } returns emptyList()
every { buildExecutor.currentBuilds() } returns emptyList()
every { buildExecutor.startBuild(any(), any(), any()) } answers {
every { buildExecutor.startBuild(any(), any(), any(), anyNullable(), any()) } answers {
val branch = firstArg<String>()
val commit = secondArg<String>()
startedBuilds += branch to commit
@@ -92,14 +93,18 @@ class WatcherTest : FunSpec() {
branch: String,
status: BuildStatus,
commit: String = "commit-0",
buildCommandOverride: String? = null,
name: String = branch,
): BuildResult {
val startedAt = noon.minusSeconds(3600).plusSeconds(seedCounter++)
val result =
BuildResult(
branch = branch,
name = name,
commit = commit,
status = status,
startedAt = startedAt,
buildCommandOverride = buildCommandOverride,
artifactKey = ArtifactKeys.buildKey(branch, startedAt),
)
repository.append(result)
@@ -136,7 +141,7 @@ class WatcherTest : FunSpec() {
branches =
mapOf(
"default" to BranchConfig(),
"main" to BranchConfig(autoBuild = AutoBuildConfig(enabled = true, times = times.toList())),
"main" to BranchConfig(autoBuild = AutoBuildConfig(enabled = true, times = times.map { AutoBuildSlot(it) })),
),
)
@@ -349,7 +354,7 @@ class WatcherTest : FunSpec() {
"main" to
BranchConfig(
requirePullRequest = true,
autoBuild = AutoBuildConfig(enabled = true, times = listOf("11:00")),
autoBuild = AutoBuildConfig(enabled = true, times = listOf(AutoBuildSlot("11:00"))),
),
),
),
@@ -374,9 +379,80 @@ class WatcherTest : FunSpec() {
harness.watcher.poll(harness.workingDir)
harness.startedBuilds shouldContainExactly listOf("main" to "commit-abc")
// a plain HH:MM slot runs the branch's regular buildCommand — no override
verify { harness.buildExecutor.startBuild("main", "commit-abc", any(), null) }
harness.autoBuildState().isTriggered("main", LocalDate.parse("2026-07-07"), "11:00").shouldBeTrue()
}
test("an auto-build slot with its own build command dictates that command for the build") {
val harness =
Harness(
GitTallyConfig(
branches =
mapOf(
"default" to BranchConfig(),
"main" to
BranchConfig(
autoBuild =
AutoBuildConfig(
enabled = true,
times = listOf(AutoBuildSlot("11:00", "./gradlew fullCheck")),
),
),
),
),
)
harness.seed("main", BuildStatus.SUCCESS, commit = "commit-abc")
every { harness.gitService.originBranches(any()) } returns listOf("main")
every { harness.gitService.originHeadCommit("main", any()) } returns "commit-abc"
harness.watcher.poll(harness.workingDir)
verify { harness.buildExecutor.startBuild("main", "commit-abc", any(), "./gradlew fullCheck") }
harness.autoBuildState().isTriggered("main", LocalDate.parse("2026-07-07"), "11:00").shouldBeTrue()
}
test("a named auto-build slot records under its name, even while the branch's regular build is running") {
val harness =
Harness(
GitTallyConfig(
branches =
mapOf(
"default" to BranchConfig(),
"main" to
BranchConfig(
autoBuild =
AutoBuildConfig(
enabled = true,
times = listOf(AutoBuildSlot("11:00", "./gradlew fullCheck", "main@nightly")),
),
),
),
),
)
// the branch's own pool is busy; the named slot has its own pool and is not blocked by it
harness.seed("main", BuildStatus.RUNNING, commit = "commit-abc")
every { harness.gitService.originBranches(any()) } returns listOf("main")
every { harness.gitService.originHeadCommit("main", any()) } returns "commit-abc"
harness.watcher.poll(harness.workingDir)
verify { harness.buildExecutor.startBuild("main", "commit-abc", any(), "./gradlew fullCheck", "main@nightly") }
harness.autoBuildState().isTriggered("main", LocalDate.parse("2026-07-07"), "11:00").shouldBeTrue()
}
test("a commit-triggered build never carries a build command override") {
val harness = Harness()
every { harness.gitService.originBranches(any()) } returns listOf("main")
every { harness.gitService.localBranches(any()) } returns listOf("main")
every { harness.gitService.hasNewCommits("main", any()) } returns true
every { harness.gitService.originHeadCommit("main", any()) } returns "commit-main"
harness.watcher.poll(harness.workingDir)
verify { harness.buildExecutor.startBuild("main", "commit-main", any(), null) }
}
test("an auto-build slot stays untriggered while the branch is still building") {
val harness = Harness(autoBuildConfig("11:00"))
harness.seed("main", BuildStatus.RUNNING, commit = "commit-abc")
@@ -420,6 +496,23 @@ class WatcherTest : FunSpec() {
harness.startedBuilds shouldContainExactly listOf("main" to "commit-2")
}
test("startup recovery re-enqueues an interrupted auto-slot build with its recorded command and name") {
val harness = Harness()
harness.seed(
"main",
BuildStatus.INTERRUPTED,
commit = "commit-1",
buildCommandOverride = "./gradlew fullCheck",
name = "main@nightly",
)
every { harness.gitService.originHeadCommit("main", any()) } returns "commit-1"
harness.watcher.recoverOnStartup(harness.workingDir)
// otherwise a restart mid-nightly-build would repeat it with the regular command, in the wrong pool
verify { harness.buildExecutor.startBuild("main", "commit-1", any(), "./gradlew fullCheck", "main@nightly") }
}
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")