implemented 06-watcher.md: non-blocking poll cycle enqueueing changed/new/auto-build branches via the async executor, startup recovery, retention/worktree pruning, JSON auto-build slot state, watcher.pollInterval config, and watcher health state
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
3c9eeda5da
commit
a1db450bdb
@@ -126,6 +126,8 @@ class InitCommand(
|
||||
|
||||
# Controls the branch-polling loop.
|
||||
watcher:
|
||||
# delay between poll cycles (s/m/h/d suffix)
|
||||
pollInterval: 10s
|
||||
# max commit age for new origin branches to be pulled automatically
|
||||
newBranchMaxAge: 5d
|
||||
|
||||
|
||||
@@ -2,18 +2,25 @@ package de.hoennig.gittally.config
|
||||
|
||||
import java.time.Duration
|
||||
|
||||
/** Parses durations in the `watcher.newBranchMaxAge` format: `5d` (days) or `12h` (hours). */
|
||||
/**
|
||||
* Parses durations in the format used by `watcher.newBranchMaxAge` and `watcher.pollInterval`:
|
||||
* `5d` (days), `12h` (hours), `10m` (minutes), or `30s` (seconds).
|
||||
*/
|
||||
object DurationParser {
|
||||
private val pattern = Regex("""(\d+)([dh])""")
|
||||
private val pattern = Regex("""(\d+)([dhms])""")
|
||||
|
||||
fun parse(value: String): Duration {
|
||||
val match =
|
||||
pattern.matchEntire(value.trim())
|
||||
?: throw IllegalArgumentException("invalid duration '$value': expected <amount>d or <amount>h, e.g. 5d or 12h")
|
||||
?: throw IllegalArgumentException(
|
||||
"invalid duration '$value': expected <amount>d, <amount>h, <amount>m, or <amount>s, e.g. 5d or 10s",
|
||||
)
|
||||
val (amount, unit) = match.destructured
|
||||
return when (unit) {
|
||||
"d" -> Duration.ofDays(amount.toLong())
|
||||
else -> Duration.ofHours(amount.toLong())
|
||||
"h" -> Duration.ofHours(amount.toLong())
|
||||
"m" -> Duration.ofMinutes(amount.toLong())
|
||||
else -> Duration.ofSeconds(amount.toLong())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,6 +41,8 @@ data class ArtifactsConfig(
|
||||
)
|
||||
|
||||
data class WatcherConfig(
|
||||
/** Delay between poll cycles, e.g. `10s` or `1m`. */
|
||||
val pollInterval: String = "10s",
|
||||
val newBranchMaxAge: String = "5d",
|
||||
)
|
||||
|
||||
|
||||
@@ -151,6 +151,15 @@ class GitService(
|
||||
.trim()
|
||||
.ifEmpty { null }
|
||||
|
||||
/** The commit `refs/remotes/origin/[branch]` points at, or null when the branch is not on origin. */
|
||||
fun originHeadCommit(
|
||||
branch: String,
|
||||
workingDir: Path = Paths.get("."),
|
||||
): String? {
|
||||
val result = runner.run(listOf("git", "rev-parse", "--verify", "refs/remotes/origin/$branch"), workingDir)
|
||||
return if (result.isSuccess) result.stdout.trim() else null
|
||||
}
|
||||
|
||||
fun headCommit(workingDir: Path = Paths.get(".")): String =
|
||||
runner
|
||||
.runOrThrow(listOf("git", "rev-parse", "HEAD"), workingDir)
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
package de.hoennig.gittally.watcher
|
||||
|
||||
import com.fasterxml.jackson.databind.DeserializationFeature
|
||||
import com.fasterxml.jackson.databind.ObjectMapper
|
||||
import com.fasterxml.jackson.databind.SerializationFeature
|
||||
import com.fasterxml.jackson.module.kotlin.readValue
|
||||
import com.fasterxml.jackson.module.kotlin.registerKotlinModule
|
||||
import org.slf4j.LoggerFactory
|
||||
import java.nio.file.Files
|
||||
import java.nio.file.Path
|
||||
import java.nio.file.StandardCopyOption
|
||||
import java.time.LocalDate
|
||||
import java.time.LocalTime
|
||||
import java.time.format.DateTimeParseException
|
||||
|
||||
/** One recorded auto-build trigger: [branch] was enqueued for the [slot] (UTC `HH:MM`) of [date] (ISO). */
|
||||
data class AutoBuildTrigger(
|
||||
val branch: String,
|
||||
val date: String,
|
||||
val slot: String,
|
||||
)
|
||||
|
||||
/** Auto-build time slot matching (UTC `HH:MM`), like legacy `auto_build_check`. */
|
||||
object AutoBuildSlots {
|
||||
private val log = LoggerFactory.getLogger(AutoBuildSlots::class.java)
|
||||
|
||||
/** The latest valid slot at or before [now], or null when no slot is due yet today. */
|
||||
fun latestDueSlot(
|
||||
times: List<String>,
|
||||
now: LocalTime,
|
||||
): String? =
|
||||
times
|
||||
.mapNotNull { slot ->
|
||||
try {
|
||||
LocalTime.parse(slot.trim()) to slot
|
||||
} catch (_: DateTimeParseException) {
|
||||
log.warn("skipping invalid auto-build time slot '{}': expected HH:MM", slot)
|
||||
null
|
||||
}
|
||||
}.filter { (parsed, _) -> !parsed.isAfter(now) }
|
||||
.maxByOrNull { (parsed, _) -> parsed }
|
||||
?.second
|
||||
}
|
||||
|
||||
/**
|
||||
* Persists which auto-build slots already triggered as a JSON file,
|
||||
* e.g. `.git/gittally/auto-builds.json` (replaces the legacy `auto-builds.tsv`).
|
||||
* Entries of past days are dropped on write, so the file never grows unbounded.
|
||||
*/
|
||||
class FileAutoBuildState(
|
||||
private val file: Path,
|
||||
) {
|
||||
private val log = LoggerFactory.getLogger(FileAutoBuildState::class.java)
|
||||
|
||||
private val json =
|
||||
ObjectMapper()
|
||||
.registerKotlinModule()
|
||||
.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)
|
||||
.configure(SerializationFeature.INDENT_OUTPUT, true)
|
||||
|
||||
fun isTriggered(
|
||||
branch: String,
|
||||
date: LocalDate,
|
||||
slot: String,
|
||||
): Boolean = AutoBuildTrigger(branch, date.toString(), slot) in load()
|
||||
|
||||
fun markTriggered(
|
||||
branch: String,
|
||||
date: LocalDate,
|
||||
slot: String,
|
||||
) {
|
||||
val current = load().filter { it.date == date.toString() }
|
||||
save(current + AutoBuildTrigger(branch, date.toString(), slot))
|
||||
}
|
||||
|
||||
private fun load(): List<AutoBuildTrigger> {
|
||||
if (!Files.exists(file)) {
|
||||
return emptyList()
|
||||
}
|
||||
return try {
|
||||
json.readValue<List<AutoBuildTrigger>>(file.toFile())
|
||||
} catch (e: Exception) {
|
||||
log.warn("ignoring unreadable auto-builds file {}: {}", file, e.message)
|
||||
emptyList()
|
||||
}
|
||||
}
|
||||
|
||||
private fun save(triggers: List<AutoBuildTrigger>) {
|
||||
Files.createDirectories(file.parent)
|
||||
val tempFile = Files.createTempFile(file.parent, file.fileName.toString(), ".tmp")
|
||||
try {
|
||||
json.writeValue(tempFile.toFile(), triggers)
|
||||
Files.move(tempFile, file, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING)
|
||||
} finally {
|
||||
Files.deleteIfExists(tempFile)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,270 @@
|
||||
package de.hoennig.gittally.watcher
|
||||
|
||||
import de.hoennig.gittally.build.ArtifactKeys
|
||||
import de.hoennig.gittally.build.ArtifactStore
|
||||
import de.hoennig.gittally.build.BuildExecutor
|
||||
import de.hoennig.gittally.build.BuildResultRepository
|
||||
import de.hoennig.gittally.build.BuildStatus
|
||||
import de.hoennig.gittally.build.GitWorktreeWorkspaces
|
||||
import de.hoennig.gittally.config.ConfigLoader
|
||||
import de.hoennig.gittally.config.DurationParser
|
||||
import de.hoennig.gittally.config.GitTallyConfig
|
||||
import de.hoennig.gittally.git.GitService
|
||||
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.LocalDate
|
||||
import java.time.LocalTime
|
||||
import java.time.ZoneOffset
|
||||
import java.util.concurrent.Executors
|
||||
import java.util.concurrent.ScheduledExecutorService
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
/**
|
||||
* Replaces the legacy blocking main loop: a non-blocking fixed-delay poll cycle that
|
||||
* fetches origin, enqueues due branches via the async [BuildExecutor], and prunes
|
||||
* retention — it never waits for a build and never touches the primary checkout.
|
||||
* The loop only runs after an explicit [start] (server/watch mode, step 07);
|
||||
* nothing is scheduled during CLI commands or tests.
|
||||
*/
|
||||
@Service
|
||||
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,
|
||||
) {
|
||||
private val log = LoggerFactory.getLogger(Watcher::class.java)
|
||||
|
||||
private var scheduler: ScheduledExecutorService? = null
|
||||
|
||||
@Volatile
|
||||
private var state = WatcherState()
|
||||
|
||||
fun state(): WatcherState = state
|
||||
|
||||
/**
|
||||
* 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(".")) {
|
||||
check(scheduler == null) { "watcher is already running" }
|
||||
recoverOnStartup(workingDir)
|
||||
val interval = DurationParser.parse(configLoader.load(workingDir).watcher.pollInterval)
|
||||
scheduler =
|
||||
Executors
|
||||
.newSingleThreadScheduledExecutor { runnable ->
|
||||
Thread(runnable, "gittally-watcher").apply { isDaemon = true }
|
||||
}.also {
|
||||
it.scheduleWithFixedDelay({ pollSafely(workingDir) }, 0, interval.toMillis(), TimeUnit.MILLISECONDS)
|
||||
}
|
||||
state = state.copy(running = true)
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun stop() {
|
||||
scheduler?.shutdownNow()
|
||||
scheduler = null
|
||||
state = state.copy(running = false)
|
||||
}
|
||||
|
||||
/**
|
||||
* Port of the legacy startup recovery: best-effort fetch, mark stale RUNNING and
|
||||
* 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(".")) {
|
||||
try {
|
||||
gitService.fetchOrigin(workingDir)
|
||||
} catch (e: Exception) {
|
||||
log.warn("startup fetch failed; recovering from the last known origin state: {}", e.message)
|
||||
}
|
||||
repository.markStaleRunningAsInterrupted().forEach {
|
||||
log.info("marked stale build of branch {} as interrupted", it.branch)
|
||||
}
|
||||
val restartable =
|
||||
repository
|
||||
.latestPerBranch()
|
||||
.filter { it.status == BuildStatus.INTERRUPTED || it.status == BuildStatus.PENDING }
|
||||
for (result in restartable) {
|
||||
val commit = gitService.originHeadCommit(result.branch, workingDir)
|
||||
if (commit == null) {
|
||||
log.info("not restarting build of branch {}: branch is gone from origin", result.branch)
|
||||
continue
|
||||
}
|
||||
if (result.status == BuildStatus.PENDING) {
|
||||
// the executor queue did not survive the restart; the re-enqueued build supersedes the stale entry
|
||||
repository.updateByArtifactKey(result.artifactKey) { it.copy(status = BuildStatus.INTERRUPTED) }
|
||||
}
|
||||
log.info("restarting unfinished build of branch {}", result.branch)
|
||||
buildExecutor.startBuild(result.branch, commit, workingDir)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One poll cycle, never blocking on a build: fetch origin (on failure: log, expose
|
||||
* in [state], retry next cycle), enqueue due branches — changed local branches
|
||||
* first, then recent new origin branches, then due auto-build slots — and finally
|
||||
* prune results, artifacts, and worktrees of branches gone from origin.
|
||||
*/
|
||||
fun poll(workingDir: Path = Paths.get(".")) {
|
||||
val startedAt = clock.instant()
|
||||
try {
|
||||
gitService.fetchOrigin(workingDir)
|
||||
} catch (e: Exception) {
|
||||
log.warn("fetching origin failed; retrying next cycle: {}", e.message)
|
||||
state = state.copy(lastPollAt = startedAt, lastFetchError = e.message ?: e.javaClass.simpleName)
|
||||
return
|
||||
}
|
||||
val config = configLoader.load(workingDir)
|
||||
val originBranches = gitService.originBranches(workingDir)
|
||||
enqueueDueBranches(config, originBranches.toSet(), workingDir)
|
||||
prune(config, originBranches, workingDir)
|
||||
state =
|
||||
state.copy(
|
||||
lastPollAt = startedAt,
|
||||
lastFetchError = null,
|
||||
lastPollError = null,
|
||||
queuedBranches =
|
||||
repository
|
||||
.latestPerBranch()
|
||||
.filter { it.status == BuildStatus.PENDING || it.status == BuildStatus.RUNNING }
|
||||
.map { it.branch },
|
||||
)
|
||||
}
|
||||
|
||||
private fun pollSafely(workingDir: Path) {
|
||||
try {
|
||||
poll(workingDir)
|
||||
} catch (e: Exception) {
|
||||
log.error("poll cycle failed", e)
|
||||
state = state.copy(lastPollAt = clock.instant(), lastPollError = e.message ?: e.javaClass.simpleName)
|
||||
}
|
||||
}
|
||||
|
||||
private fun enqueueDueBranches(
|
||||
config: GitTallyConfig,
|
||||
originBranches: Set<String>,
|
||||
workingDir: Path,
|
||||
) {
|
||||
val changedLocal =
|
||||
gitService
|
||||
.localBranches(workingDir)
|
||||
.filter { it in originBranches && gitService.hasNewCommits(it, workingDir) }
|
||||
val newOrigin =
|
||||
gitService.newOriginBranches(DurationParser.parse(config.watcher.newBranchMaxAge), workingDir)
|
||||
for (branch in (changedLocal + newOrigin).distinct()) {
|
||||
startBuildIfDue(branch, allowSameCommit = false, workingDir = workingDir)
|
||||
}
|
||||
enqueueAutoBuilds(config, originBranches, workingDir)
|
||||
}
|
||||
|
||||
/**
|
||||
* Enqueues a build of the branch's origin head unless one is already pending or
|
||||
* running, or that commit was already built. Builds run detached in worktrees and
|
||||
* never move local branch refs, so "already built" is tracked via the result
|
||||
* repository, not by resetting the local ref like legacy. A new commit for a
|
||||
* branch that is still pending/running waits for a later cycle (queue-behind).
|
||||
*/
|
||||
private fun startBuildIfDue(
|
||||
branch: String,
|
||||
allowSameCommit: Boolean,
|
||||
workingDir: Path,
|
||||
): Boolean {
|
||||
val latest = repository.latestFor(branch)
|
||||
if (latest?.status == BuildStatus.PENDING || latest?.status == BuildStatus.RUNNING) {
|
||||
return false
|
||||
}
|
||||
val commit = gitService.originHeadCommit(branch, workingDir) ?: return false
|
||||
if (!allowSameCommit && latest?.commit == commit) {
|
||||
return false
|
||||
}
|
||||
log.info("enqueueing build of branch {} at commit {}", branch, commit)
|
||||
buildExecutor.startBuild(branch, commit, workingDir)
|
||||
return true
|
||||
}
|
||||
|
||||
private fun enqueueAutoBuilds(
|
||||
config: GitTallyConfig,
|
||||
originBranches: Set<String>,
|
||||
workingDir: Path,
|
||||
) {
|
||||
val autoBuildBranches =
|
||||
config.branches.filter { (branch, branchConfig) ->
|
||||
branch != "default" && branchConfig.autoBuild.enabled
|
||||
}
|
||||
if (autoBuildBranches.isEmpty()) {
|
||||
return
|
||||
}
|
||||
val autoBuildState = FileAutoBuildState(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, branchConfig) in autoBuildBranches) {
|
||||
val slot = AutoBuildSlots.latestDueSlot(branchConfig.autoBuild.times, timeOfDay) ?: continue
|
||||
if (autoBuildState.isTriggered(branch, today, slot)) {
|
||||
continue
|
||||
}
|
||||
if (branch !in originBranches) {
|
||||
log.warn("skipping auto build of branch {}: branch is not on origin", branch)
|
||||
continue
|
||||
}
|
||||
// rebuilding the already-built commit is the point of an auto build
|
||||
if (startBuildIfDue(branch, allowSameCommit = true, workingDir = workingDir)) {
|
||||
autoBuildState.markTriggered(branch, today, slot)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Results first, then artifacts of dropped results, then worktrees of branches gone from origin. */
|
||||
private fun prune(
|
||||
config: GitTallyConfig,
|
||||
originBranches: List<String>,
|
||||
workingDir: Path,
|
||||
) {
|
||||
repository.prune(originBranches, config.artifacts.retentionPerBranch)
|
||||
artifactStore.prune(repository.history())
|
||||
pruneWorktrees(originBranches, workingDir)
|
||||
}
|
||||
|
||||
private fun pruneWorktrees(
|
||||
originBranches: List<String>,
|
||||
workingDir: Path,
|
||||
) {
|
||||
val worktreesDir = workingDir.resolve(GitWorktreeWorkspaces.WORKTREES_DIR)
|
||||
if (!Files.isDirectory(worktreesDir)) {
|
||||
return
|
||||
}
|
||||
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
|
||||
.latestPerBranch()
|
||||
.filter { it.status == BuildStatus.PENDING || it.status == BuildStatus.RUNNING }
|
||||
.forEach { keep += ArtifactKeys.branchKey(it.branch) }
|
||||
var removed = false
|
||||
Files.list(worktreesDir).use { entries ->
|
||||
entries.forEach { entry ->
|
||||
if (Files.isDirectory(entry) && entry.fileName.toString() !in keep) {
|
||||
log.info("removing worktree of branch gone from origin: {}", entry.fileName)
|
||||
entry.toFile().deleteRecursively()
|
||||
removed = true
|
||||
}
|
||||
}
|
||||
}
|
||||
if (removed) {
|
||||
gitService.worktreePrune(workingDir)
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
/** Auto-build trigger state next to the build results (replaces legacy `auto-builds.tsv`). */
|
||||
const val AUTO_BUILDS_FILE = ".git/gittally/auto-builds.json"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package de.hoennig.gittally.watcher
|
||||
|
||||
import org.springframework.context.annotation.Bean
|
||||
import org.springframework.context.annotation.Configuration
|
||||
import java.time.Clock
|
||||
|
||||
@Configuration
|
||||
class WatcherConfiguration {
|
||||
/** UTC clock, injectable in tests; auto-build slots are UTC `HH:MM` like legacy. */
|
||||
@Bean
|
||||
fun clock(): Clock = Clock.systemUTC()
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package de.hoennig.gittally.watcher
|
||||
|
||||
import java.time.Instant
|
||||
|
||||
/** Observable watcher health for status endpoints (step 07) and the UI (step 08). */
|
||||
data class WatcherState(
|
||||
/** Whether the poll loop is scheduled. */
|
||||
val running: Boolean = false,
|
||||
/** When the last poll cycle started, successful or not. */
|
||||
val lastPollAt: Instant? = null,
|
||||
/** Why the last `fetchOrigin` failed; null after a successful fetch. */
|
||||
val lastFetchError: String? = null,
|
||||
/** Why the last poll cycle crashed after a successful fetch; null after a clean cycle. */
|
||||
val lastPollError: String? = null,
|
||||
/** Branches whose latest build was PENDING or RUNNING at the end of the last poll. */
|
||||
val queuedBranches: List<String> = emptyList(),
|
||||
)
|
||||
@@ -16,6 +16,14 @@ class DurationParserTest : FunSpec() {
|
||||
DurationParser.parse("12h") shouldBe Duration.ofHours(12)
|
||||
}
|
||||
|
||||
test("parses minutes") {
|
||||
DurationParser.parse("10m") shouldBe Duration.ofMinutes(10)
|
||||
}
|
||||
|
||||
test("parses seconds") {
|
||||
DurationParser.parse("30s") shouldBe Duration.ofSeconds(30)
|
||||
}
|
||||
|
||||
test("parses multi-digit amounts") {
|
||||
DurationParser.parse("120h") shouldBe Duration.ofHours(120)
|
||||
}
|
||||
|
||||
@@ -217,6 +217,13 @@ class GitServiceTest : FunSpec() {
|
||||
service.headCommit(fixture.work) shouldMatch Regex("[0-9a-f]{40}")
|
||||
}
|
||||
|
||||
test("originHeadCommit returns the origin branch head, or null for an unknown branch") {
|
||||
val fixture = Fixture()
|
||||
|
||||
service.originHeadCommit("main", fixture.work) shouldBe service.headCommit(fixture.work)
|
||||
service.originHeadCommit("no-such-branch", fixture.work).shouldBeNull()
|
||||
}
|
||||
|
||||
test("worktreeAdd creates a detached worktree at the commit") {
|
||||
val fixture = Fixture()
|
||||
val head = service.headCommit(fixture.work)
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
package de.hoennig.gittally.watcher
|
||||
|
||||
import io.kotest.core.spec.style.FunSpec
|
||||
import io.kotest.matchers.booleans.shouldBeFalse
|
||||
import io.kotest.matchers.booleans.shouldBeTrue
|
||||
import io.kotest.matchers.nulls.shouldBeNull
|
||||
import io.kotest.matchers.shouldBe
|
||||
import java.nio.file.Files
|
||||
import java.nio.file.Path
|
||||
import java.time.LocalDate
|
||||
import java.time.LocalTime
|
||||
|
||||
class AutoBuildStateTest : FunSpec() {
|
||||
private fun stateFile(): Path = Files.createTempDirectory("gittally-autobuild-test").resolve("auto-builds.json")
|
||||
|
||||
init {
|
||||
test("latestDueSlot picks the latest slot at or before now") {
|
||||
val times = listOf("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"
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
|
||||
test("latestDueSlot of an empty slot list is null") {
|
||||
AutoBuildSlots.latestDueSlot(emptyList(), LocalTime.parse("12:00")).shouldBeNull()
|
||||
}
|
||||
|
||||
test("markTriggered records exactly the branch, day, and slot") {
|
||||
val state = FileAutoBuildState(stateFile())
|
||||
val today = LocalDate.parse("2026-07-07")
|
||||
|
||||
state.isTriggered("main", today, "11:00").shouldBeFalse()
|
||||
state.markTriggered("main", today, "11:00")
|
||||
|
||||
state.isTriggered("main", today, "11:00").shouldBeTrue()
|
||||
state.isTriggered("main", today, "13:00").shouldBeFalse()
|
||||
state.isTriggered("main", today.plusDays(1), "11:00").shouldBeFalse()
|
||||
state.isTriggered("other", today, "11:00").shouldBeFalse()
|
||||
}
|
||||
|
||||
test("triggers persist across instances") {
|
||||
val file = stateFile()
|
||||
val today = LocalDate.parse("2026-07-07")
|
||||
FileAutoBuildState(file).markTriggered("main", today, "11:00")
|
||||
|
||||
FileAutoBuildState(file).isTriggered("main", today, "11:00").shouldBeTrue()
|
||||
}
|
||||
|
||||
test("entries of past days are dropped on write") {
|
||||
val file = stateFile()
|
||||
val state = FileAutoBuildState(file)
|
||||
val yesterday = LocalDate.parse("2026-07-06")
|
||||
val today = LocalDate.parse("2026-07-07")
|
||||
state.markTriggered("main", yesterday, "11:00")
|
||||
|
||||
state.markTriggered("main", today, "01:00")
|
||||
|
||||
state.isTriggered("main", yesterday, "11:00").shouldBeFalse()
|
||||
state.isTriggered("main", today, "01:00").shouldBeTrue()
|
||||
}
|
||||
|
||||
test("an unreadable state file is treated as empty") {
|
||||
val file = stateFile()
|
||||
Files.createDirectories(file.parent)
|
||||
Files.writeString(file, "not json at all {")
|
||||
val state = FileAutoBuildState(file)
|
||||
|
||||
state.isTriggered("main", LocalDate.parse("2026-07-07"), "11:00").shouldBeFalse()
|
||||
state.markTriggered("main", LocalDate.parse("2026-07-07"), "11:00")
|
||||
state.isTriggered("main", LocalDate.parse("2026-07-07"), "11:00").shouldBeTrue()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,343 @@
|
||||
package de.hoennig.gittally.watcher
|
||||
|
||||
import de.hoennig.gittally.build.ArtifactKeys
|
||||
import de.hoennig.gittally.build.ArtifactStore
|
||||
import de.hoennig.gittally.build.BuildExecutor
|
||||
import de.hoennig.gittally.build.BuildResult
|
||||
import de.hoennig.gittally.build.BuildStatus
|
||||
import de.hoennig.gittally.build.FileBuildResultRepository
|
||||
import de.hoennig.gittally.build.GitWorktreeWorkspaces
|
||||
import de.hoennig.gittally.build.RunningBuild
|
||||
import de.hoennig.gittally.config.AutoBuildConfig
|
||||
import de.hoennig.gittally.config.BranchConfig
|
||||
import de.hoennig.gittally.config.ConfigLoader
|
||||
import de.hoennig.gittally.config.GitTallyConfig
|
||||
import de.hoennig.gittally.config.WatcherConfig
|
||||
import de.hoennig.gittally.git.GitService
|
||||
import io.kotest.assertions.throwables.shouldThrow
|
||||
import io.kotest.core.spec.style.FunSpec
|
||||
import io.kotest.matchers.booleans.shouldBeFalse
|
||||
import io.kotest.matchers.booleans.shouldBeTrue
|
||||
import io.kotest.matchers.collections.shouldBeEmpty
|
||||
import io.kotest.matchers.collections.shouldContainExactly
|
||||
import io.kotest.matchers.collections.shouldContainExactlyInAnyOrder
|
||||
import io.kotest.matchers.nulls.shouldBeNull
|
||||
import io.kotest.matchers.nulls.shouldNotBeNull
|
||||
import io.kotest.matchers.shouldBe
|
||||
import io.kotest.matchers.string.shouldContain
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import io.mockk.verify
|
||||
import java.nio.file.Files
|
||||
import java.nio.file.Path
|
||||
import java.time.Clock
|
||||
import java.time.Duration
|
||||
import java.time.Instant
|
||||
import java.time.LocalDate
|
||||
import java.time.ZoneOffset
|
||||
import java.util.concurrent.CopyOnWriteArrayList
|
||||
import java.util.concurrent.CountDownLatch
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
class WatcherTest : FunSpec() {
|
||||
private val noon = Instant.parse("2026-07-07T12:00:00Z")
|
||||
|
||||
private inner class Harness(
|
||||
config: GitTallyConfig = GitTallyConfig(),
|
||||
) {
|
||||
val workingDir: Path = Files.createTempDirectory("gittally-watcher-test")
|
||||
val repository = FileBuildResultRepository(workingDir.resolve(".git/gittally/build-results.json"))
|
||||
val gitService = mockk<GitService>()
|
||||
val buildExecutor = mockk<BuildExecutor>()
|
||||
val artifactStore = mockk<ArtifactStore>()
|
||||
val startedBuilds = CopyOnWriteArrayList<Pair<String, String>>()
|
||||
val configLoader = mockk<ConfigLoader>()
|
||||
val watcher =
|
||||
Watcher(
|
||||
gitService = gitService,
|
||||
buildExecutor = buildExecutor,
|
||||
repository = repository,
|
||||
artifactStore = artifactStore,
|
||||
configLoader = configLoader,
|
||||
clock = Clock.fixed(noon, ZoneOffset.UTC),
|
||||
)
|
||||
|
||||
private var seedCounter = 0L
|
||||
|
||||
init {
|
||||
every { configLoader.load(any()) } returns config
|
||||
every { gitService.fetchOrigin(any()) } returns Unit
|
||||
every { gitService.localBranches(any()) } returns emptyList()
|
||||
every { gitService.originBranches(any()) } returns emptyList()
|
||||
every { gitService.newOriginBranches(any(), any()) } returns emptyList()
|
||||
every { gitService.hasNewCommits(any(), any()) } returns false
|
||||
every { gitService.originHeadCommit(any(), any()) } returns null
|
||||
every { gitService.worktreePrune(any()) } returns Unit
|
||||
every { buildExecutor.currentBuilds() } returns emptyList()
|
||||
every { buildExecutor.startBuild(any(), any(), any()) } answers {
|
||||
val branch = firstArg<String>()
|
||||
val commit = secondArg<String>()
|
||||
startedBuilds += branch to commit
|
||||
runningBuild(branch, commit)
|
||||
}
|
||||
every { artifactStore.prune(any()) } returns emptyList()
|
||||
}
|
||||
|
||||
/** Appends a result; later seeds get later start timestamps. */
|
||||
fun seed(
|
||||
branch: String,
|
||||
status: BuildStatus,
|
||||
commit: String = "commit-0",
|
||||
): BuildResult {
|
||||
val startedAt = noon.minusSeconds(3600).plusSeconds(seedCounter++)
|
||||
val result =
|
||||
BuildResult(
|
||||
branch = branch,
|
||||
commit = commit,
|
||||
status = status,
|
||||
startedAt = startedAt,
|
||||
artifactKey = ArtifactKeys.buildKey(branch, startedAt),
|
||||
)
|
||||
repository.append(result)
|
||||
return result
|
||||
}
|
||||
|
||||
fun worktreeDir(branch: String): Path {
|
||||
val dir = workingDir.resolve(GitWorktreeWorkspaces.WORKTREES_DIR).resolve(ArtifactKeys.branchKey(branch))
|
||||
Files.createDirectories(dir)
|
||||
Files.writeString(dir.resolve("marker.txt"), branch)
|
||||
return dir
|
||||
}
|
||||
|
||||
fun autoBuildState() = FileAutoBuildState(workingDir.resolve(Watcher.AUTO_BUILDS_FILE))
|
||||
}
|
||||
|
||||
private fun runningBuild(
|
||||
branch: String,
|
||||
commit: String,
|
||||
): RunningBuild {
|
||||
val stagingDir = Files.createTempDirectory("gittally-watcher-staging")
|
||||
return RunningBuild(
|
||||
branch = branch,
|
||||
commit = commit,
|
||||
artifactKey = ArtifactKeys.buildKey(branch, Instant.now()),
|
||||
startedAt = Instant.now(),
|
||||
stagingDir = stagingDir,
|
||||
liveLogFile = stagingDir.resolve("build.log"),
|
||||
)
|
||||
}
|
||||
|
||||
private fun autoBuildConfig(vararg times: String): GitTallyConfig =
|
||||
GitTallyConfig(
|
||||
branches =
|
||||
mapOf(
|
||||
"default" to BranchConfig(),
|
||||
"main" to BranchConfig(autoBuild = AutoBuildConfig(enabled = true, times = times.toList())),
|
||||
),
|
||||
)
|
||||
|
||||
init {
|
||||
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")
|
||||
|
||||
harness.watcher.poll(harness.workingDir)
|
||||
|
||||
harness.watcher
|
||||
.state()
|
||||
.lastFetchError
|
||||
.shouldNotBeNull() shouldContain "origin unreachable"
|
||||
harness.watcher.state().lastPollAt shouldBe noon
|
||||
harness.startedBuilds.shouldBeEmpty()
|
||||
verify(exactly = 0) { harness.artifactStore.prune(any()) }
|
||||
|
||||
every { harness.gitService.fetchOrigin(any()) } returns Unit
|
||||
harness.watcher.poll(harness.workingDir)
|
||||
|
||||
harness.watcher
|
||||
.state()
|
||||
.lastFetchError
|
||||
.shouldBeNull()
|
||||
}
|
||||
|
||||
test("poll enqueues changed local branches before recent new origin branches") {
|
||||
val harness = Harness()
|
||||
every { harness.gitService.originBranches(any()) } returns listOf("main", "feature/new")
|
||||
every { harness.gitService.localBranches(any()) } returns listOf("main", "untracked-local")
|
||||
every { harness.gitService.hasNewCommits("main", any()) } returns true
|
||||
every { harness.gitService.newOriginBranches(any(), any()) } returns listOf("feature/new")
|
||||
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.startedBuilds shouldContainExactly
|
||||
listOf("main" to "commit-main", "feature/new" to "commit-feature")
|
||||
}
|
||||
|
||||
test("poll skips a branch whose build is already pending or running") {
|
||||
val harness = Harness()
|
||||
harness.seed("main", BuildStatus.PENDING, commit = "commit-old")
|
||||
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-new"
|
||||
|
||||
harness.watcher.poll(harness.workingDir)
|
||||
|
||||
harness.startedBuilds.shouldBeEmpty()
|
||||
}
|
||||
|
||||
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.gitService.originBranches(any()) } returns listOf("main", "feature/other")
|
||||
every { harness.gitService.localBranches(any()) } returns listOf("main")
|
||||
every { harness.gitService.hasNewCommits("main", any()) } returns true
|
||||
every { harness.gitService.newOriginBranches(any(), any()) } returns listOf("feature/other")
|
||||
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.startedBuilds shouldContainExactly listOf("feature/other" to "commit-3")
|
||||
harness.watcher.state().queuedBranches shouldContainExactly listOf("main")
|
||||
}
|
||||
|
||||
test("poll does not re-enqueue a commit that was already built") {
|
||||
val harness = Harness()
|
||||
harness.seed("main", BuildStatus.SUCCESS, commit = "commit-abc")
|
||||
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-abc"
|
||||
|
||||
harness.watcher.poll(harness.workingDir)
|
||||
harness.startedBuilds.shouldBeEmpty()
|
||||
|
||||
every { harness.gitService.originHeadCommit("main", any()) } returns "commit-def"
|
||||
harness.watcher.poll(harness.workingDir)
|
||||
|
||||
harness.startedBuilds shouldContainExactly listOf("main" to "commit-def")
|
||||
}
|
||||
|
||||
test("poll filters new origin branches by the configured newBranchMaxAge") {
|
||||
val harness = Harness(GitTallyConfig(watcher = WatcherConfig(newBranchMaxAge = "12h")))
|
||||
|
||||
harness.watcher.poll(harness.workingDir)
|
||||
|
||||
verify { harness.gitService.newOriginBranches(Duration.ofHours(12), any()) }
|
||||
}
|
||||
|
||||
test("auto builds rebuild the already built commit once per day and slot") {
|
||||
val harness = Harness(autoBuildConfig("01:00", "11:00", "13:00"))
|
||||
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)
|
||||
harness.watcher.poll(harness.workingDir)
|
||||
|
||||
harness.startedBuilds shouldContainExactly listOf("main" to "commit-abc")
|
||||
harness.autoBuildState().isTriggered("main", LocalDate.parse("2026-07-07"), "11:00").shouldBeTrue()
|
||||
}
|
||||
|
||||
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")
|
||||
every { harness.gitService.originBranches(any()) } returns listOf("main")
|
||||
every { harness.gitService.originHeadCommit("main", any()) } returns "commit-abc"
|
||||
|
||||
harness.watcher.poll(harness.workingDir)
|
||||
|
||||
harness.startedBuilds.shouldBeEmpty()
|
||||
harness.autoBuildState().isTriggered("main", LocalDate.parse("2026-07-07"), "11:00").shouldBeFalse()
|
||||
}
|
||||
|
||||
test("startup recovery marks stale builds interrupted and re-enqueues them") {
|
||||
val harness = Harness()
|
||||
harness.seed("main", BuildStatus.RUNNING, commit = "commit-1")
|
||||
harness.seed("feature/a", BuildStatus.INTERRUPTED, commit = "commit-2")
|
||||
harness.seed("queued", BuildStatus.PENDING, commit = "commit-3")
|
||||
harness.seed("done", BuildStatus.SUCCESS, commit = "commit-4")
|
||||
every { harness.gitService.originHeadCommit("main", any()) } returns "commit-1"
|
||||
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.startedBuilds shouldContainExactlyInAnyOrder
|
||||
listOf("main" to "commit-1", "feature/a" to "commit-2", "queued" to "commit-3")
|
||||
harness.repository.latestFor("main")!!.status shouldBe BuildStatus.INTERRUPTED
|
||||
harness.repository.latestFor("queued")!!.status shouldBe BuildStatus.INTERRUPTED
|
||||
harness.repository.latestFor("done")!!.status shouldBe BuildStatus.SUCCESS
|
||||
}
|
||||
|
||||
test("startup recovery skips branches gone from origin and survives a failing fetch") {
|
||||
val harness = Harness()
|
||||
every { harness.gitService.fetchOrigin(any()) } throws RuntimeException("origin unreachable")
|
||||
harness.seed("gone", BuildStatus.INTERRUPTED, commit = "commit-1")
|
||||
harness.seed("main", BuildStatus.INTERRUPTED, commit = "commit-2")
|
||||
every { harness.gitService.originHeadCommit("main", any()) } returns "commit-2"
|
||||
|
||||
harness.watcher.recoverOnStartup(harness.workingDir)
|
||||
|
||||
harness.startedBuilds shouldContainExactly listOf("main" to "commit-2")
|
||||
}
|
||||
|
||||
test("poll prunes results, artifacts, and worktrees of branches gone from origin") {
|
||||
val harness = Harness()
|
||||
harness.seed("main", BuildStatus.SUCCESS, commit = "commit-1")
|
||||
harness.seed("gone", BuildStatus.SUCCESS, commit = "commit-2")
|
||||
val keptWorktree = harness.worktreeDir("main")
|
||||
val removedWorktree = harness.worktreeDir("gone")
|
||||
every { harness.gitService.originBranches(any()) } returns listOf("main")
|
||||
|
||||
harness.watcher.poll(harness.workingDir)
|
||||
|
||||
harness.repository.history().map { it.branch } shouldContainExactly listOf("main")
|
||||
verify {
|
||||
harness.artifactStore.prune(
|
||||
match { results -> results.map { it.branch } == listOf("main") },
|
||||
)
|
||||
}
|
||||
Files.exists(keptWorktree).shouldBeTrue()
|
||||
Files.exists(removedWorktree).shouldBeFalse()
|
||||
verify { harness.gitService.worktreePrune(any()) }
|
||||
}
|
||||
|
||||
test("worktrees of queued or running builds are never pruned") {
|
||||
val harness = Harness()
|
||||
harness.seed("busy", BuildStatus.RUNNING, commit = "commit-1")
|
||||
val busyWorktree = harness.worktreeDir("busy")
|
||||
every { harness.gitService.originBranches(any()) } returns listOf("busy")
|
||||
|
||||
harness.watcher.poll(harness.workingDir)
|
||||
|
||||
Files.exists(busyWorktree).shouldBeTrue()
|
||||
}
|
||||
|
||||
test("start runs recovery plus an immediate first poll; stop halts the loop") {
|
||||
val harness = Harness()
|
||||
val fetches = CountDownLatch(2)
|
||||
every { harness.gitService.fetchOrigin(any()) } answers { fetches.countDown() }
|
||||
|
||||
harness.watcher.start(harness.workingDir)
|
||||
|
||||
fetches.await(5, TimeUnit.SECONDS).shouldBeTrue()
|
||||
harness.watcher
|
||||
.state()
|
||||
.running
|
||||
.shouldBeTrue()
|
||||
shouldThrow<IllegalStateException> { harness.watcher.start(harness.workingDir) }
|
||||
|
||||
harness.watcher.stop()
|
||||
|
||||
harness.watcher
|
||||
.state()
|
||||
.running
|
||||
.shouldBeFalse()
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user