implemented 04-build-executor.md incl. concurrency amendment: async builds in per-branch worktrees, builds.maxConcurrent, cancellation, live logs; fix .gitignore build/ rule that silently excluded the de.hoennig.gittally.build package (also recovers the step-01 domain files)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Michael Hoennig
2026-07-07 08:43:28 +02:00
co-authored by Claude Fable 5
parent ae3ae7aa04
commit b379bc0a6b
31 changed files with 1790 additions and 12 deletions
@@ -0,0 +1,27 @@
package de.hoennig.gittally.build
import java.security.MessageDigest
import java.time.Instant
/**
* Legacy-compatible artifact key naming: sanitized name plus a 12-char SHA-256 prefix,
* so keys are filesystem- and URL-safe but still unique for branch names that
* sanitize to the same string.
*/
object ArtifactKeys {
fun branchKey(branch: String): String = "${sanitize(branch)}-${sha256Prefix(branch)}"
fun buildKey(
branch: String,
startedAt: Instant,
): String = "${branchKey(branch)}-${sanitize(startedAt.toString())}-${sha256Prefix("$branch\t$startedAt")}"
private fun sanitize(value: String): String = value.replace(Regex("[^A-Za-z0-9._-]"), "_")
private fun sha256Prefix(value: String): String =
MessageDigest
.getInstance("SHA-256")
.digest(value.toByteArray())
.joinToString("") { "%02x".format(it) }
.take(12)
}
@@ -0,0 +1,29 @@
package de.hoennig.gittally.build
import org.slf4j.LoggerFactory
import org.springframework.stereotype.Component
import java.nio.file.Path
/**
* Takes over the staging directory of a finished build.
* The real store (naming, retention, serving) arrives in step 05.
*/
interface ArtifactStore {
fun persist(
build: BuildResult,
stagingDir: Path,
)
}
/** Placeholder until step 05: logs and leaves the staging directory untouched. */
@Component
class NoOpArtifactStore : ArtifactStore {
private val log = LoggerFactory.getLogger(NoOpArtifactStore::class.java)
override fun persist(
build: BuildResult,
stagingDir: Path,
) {
log.info("artifact store not implemented yet; leaving build output of {} in {}", build.artifactKey, stagingDir)
}
}
@@ -0,0 +1,55 @@
package de.hoennig.gittally.build
import de.hoennig.gittally.git.GitService
import org.slf4j.LoggerFactory
import org.springframework.stereotype.Component
import java.nio.file.Files
import java.nio.file.Path
/**
* Provides an isolated build workspace per branch so multiple branches can build
* concurrently without touching the primary checkout or each other.
*/
fun interface BranchWorkspaces {
/** A ready-to-build workspace for [branch] with [commit] checked out. */
fun prepare(
branch: String,
commit: String,
repoDir: Path,
): Path
}
/**
* One reusable git worktree per branch under `.git/gittally/worktrees/<branchKey>`,
* checked out detached at the requested commit. Reuse keeps incremental build
* caches; the branch's `cleanCommand` decides how much of them survives.
*/
@Component
class GitWorktreeWorkspaces(
private val gitService: GitService,
) : BranchWorkspaces {
private val log = LoggerFactory.getLogger(GitWorktreeWorkspaces::class.java)
override fun prepare(
branch: String,
commit: String,
repoDir: Path,
): Path {
val workspace = repoDir.resolve(WORKTREES_DIR).resolve(ArtifactKeys.branchKey(branch))
if (Files.exists(workspace.resolve(".git"))) {
gitService.checkoutDetached(commit, workspace)
} else {
gitService.worktreePrune(repoDir)
if (Files.exists(workspace)) {
log.warn("removing broken workspace of branch {}: {}", branch, workspace)
workspace.toFile().deleteRecursively()
}
gitService.worktreeAdd(workspace, commit, repoDir)
}
return workspace
}
companion object {
const val WORKTREES_DIR = ".git/gittally/worktrees"
}
}
@@ -0,0 +1,16 @@
package de.hoennig.gittally.build
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import java.nio.file.Paths
@Configuration
class BuildConfiguration {
/**
* Results file relative to the working directory, matching how `ConfigLoader`
* resolves the `.git/gittally/` override file. Nothing is touched until the
* first build runs, so the bean is safe outside a git repository.
*/
@Bean
fun buildResultRepository(): BuildResultRepository = FileBuildResultRepository(Paths.get(".git/gittally/build-results.json"))
}
@@ -0,0 +1,385 @@
package de.hoennig.gittally.build
import de.hoennig.gittally.config.BranchConfig
import de.hoennig.gittally.config.ConfigLoader
import de.hoennig.gittally.gitea.GiteaClient
import org.slf4j.LoggerFactory
import org.springframework.context.ApplicationEventPublisher
import org.springframework.stereotype.Service
import java.io.IOException
import java.io.InputStream
import java.io.OutputStream
import java.nio.file.Files
import java.nio.file.Path
import java.nio.file.Paths
import java.nio.file.StandardOpenOption
import java.time.Duration
import java.time.Instant
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.ExecutorService
import java.util.concurrent.Executors
import java.util.concurrent.Semaphore
import java.util.concurrent.atomic.AtomicBoolean
import kotlin.concurrent.thread
/**
* Runs builds asynchronously: up to `builds.maxConcurrent` branches at the same time
* (default 1), but never more than one build per branch. Each branch builds in its
* own git worktree via [BranchWorkspaces], never in the primary checkout.
* Every status transition is persisted via the [BuildResultRepository], published
* to Gitea (non-fatal), and emitted as a [BuildStatusChangedEvent].
*/
@Service
class BuildExecutor(
private val repository: BuildResultRepository,
private val configLoader: ConfigLoader,
private val giteaClient: GiteaClient,
private val buildRunner: BuildRunner,
private val workspaces: BranchWorkspaces,
private val artifactStore: ArtifactStore,
private val eventPublisher: ApplicationEventPublisher,
) {
private val log = LoggerFactory.getLogger(BuildExecutor::class.java)
/** One serial worker per branch enforces at most one build per branch. */
private val branchWorkers = ConcurrentHashMap<String, ExecutorService>()
/** All accepted, not yet finished builds by artifact key — queued and running. */
private val builds = ConcurrentHashMap<String, ActiveBuild>()
/** Global concurrency limit; sized from `builds.maxConcurrent` on first use. */
@Volatile
private var slots: Semaphore? = null
/** The builds currently executing, newest last (queued builds are PENDING in the repository). */
fun currentBuilds(): List<RunningBuild> = builds.values.filter { it.running }.map { it.runningBuild }
/**
* Persists a PENDING result and queues the build; returns immediately.
* A build of the same branch waits until the branch's previous build finished;
* builds of other branches run concurrently while slots are free.
*/
fun startBuild(
branch: String,
commit: String,
workingDir: Path = Paths.get("."),
): RunningBuild {
val startedAt = Instant.now()
val stagingDir = Files.createTempDirectory("gittally-build-")
val runningBuild =
RunningBuild(
branch = branch,
commit = commit,
artifactKey = ArtifactKeys.buildKey(branch, startedAt),
startedAt = startedAt,
stagingDir = stagingDir,
liveLogFile = stagingDir.resolve(LIVE_LOG_FILE),
)
val pending =
BuildResult(
branch = branch,
commit = commit,
status = BuildStatus.PENDING,
startedAt = startedAt,
duration = null,
artifactKey = runningBuild.artifactKey,
)
repository.append(pending)
eventPublisher.publishEvent(BuildStatusChangedEvent(pending))
val build = ActiveBuild(runningBuild, workingDir)
builds[runningBuild.artifactKey] = build
publishGiteaStatus(build, BuildStatus.PENDING, duration = null)
branchWorkers
.computeIfAbsent(branch) { serialWorker(it) }
.submit { execute(build) }
return runningBuild
}
/**
* Requests cancellation of the build with [artifactKey] and terminates its process
* tree (TERM, wait, KILL — like legacy `terminate_process_tree`). A queued build
* is recorded as CANCELLED once its worker picks it up.
* Returns false when no such build is queued or running.
*/
fun cancel(artifactKey: String): Boolean {
val build = builds[artifactKey] ?: return false
build.cancelled.set(true)
build.process?.let { destroyProcessTree(it) }
return true
}
private fun execute(build: ActiveBuild) {
var slot: Semaphore? = null
var finalStatus = BuildStatus.FAILED
try {
slot = slotsFor(build.workingDir)
slot.acquire()
if (build.cancelled.get()) {
finalStatus = BuildStatus.CANCELLED
return
}
build.running = true
transition(build, BuildStatus.RUNNING, duration = null)
val workspace =
workspaces.prepare(
branch = build.runningBuild.branch,
commit = build.runningBuild.commit,
repoDir = build.workingDir,
)
val exitCode = runBuildCommands(build, workspace)
finalStatus =
when {
build.cancelled.get() -> BuildStatus.CANCELLED
exitCode == 0 -> BuildStatus.SUCCESS
else -> BuildStatus.FAILED
}
} catch (e: Exception) {
finalStatus = if (build.cancelled.get()) BuildStatus.CANCELLED else BuildStatus.FAILED
log.error("build of branch {} crashed", build.runningBuild.branch, e)
appendToLiveLog(build, "\nbuild crashed: ${e.message}\n")
} finally {
val duration = Duration.between(build.runningBuild.startedAt, Instant.now())
val result = transition(build, finalStatus, duration)
try {
artifactStore.persist(result, build.runningBuild.stagingDir)
} catch (e: Exception) {
log.warn("could not persist artifacts of {}: {}", result.artifactKey, e.message)
}
builds.remove(build.runningBuild.artifactKey)
slot?.release()
}
}
/**
* The semaphore is sized once from the first build's config;
* changing `builds.maxConcurrent` requires a restart.
*/
private fun slotsFor(workingDir: Path): Semaphore {
slots?.let { return it }
synchronized(this) {
slots?.let { return it }
val maxConcurrent =
configLoader
.load(workingDir)
.builds.maxConcurrent
.coerceAtLeast(1)
return Semaphore(maxConcurrent, true).also { slots = it }
}
}
private fun serialWorker(branch: String): ExecutorService =
Executors.newSingleThreadExecutor { runnable ->
Thread(runnable, "gittally-build-${ArtifactKeys.branchKey(branch)}").apply { isDaemon = true }
}
private fun runBuildCommands(
build: ActiveBuild,
workspace: Path,
): Int {
val branchConfig = branchConfig(build.runningBuild.branch, build.workingDir)
val stagingDir = build.runningBuild.stagingDir
Files.newOutputStream(stagingDir.resolve(branchConfig.stdoutLog)).use { stdoutLog ->
Files.newOutputStream(stagingDir.resolve(branchConfig.stderrLog)).use { stderrLog ->
Files.newOutputStream(build.runningBuild.liveLogFile).use { liveLog ->
writeLiveLogHeader(liveLog, build.runningBuild, branchConfig, workspace)
if (branchConfig.cleanCommand.isNotBlank()) {
val cleanExitCode = runCommand(build, branchConfig.cleanCommand, workspace, stdoutLog, stderrLog, liveLog)
if (cleanExitCode != 0) {
return cleanExitCode
}
}
if (build.cancelled.get()) {
return CANCELLED_EXIT_CODE
}
return runCommand(build, branchConfig.buildCommand, workspace, stdoutLog, stderrLog, liveLog)
}
}
}
}
private fun runCommand(
build: ActiveBuild,
command: String,
workspace: Path,
stdoutLog: OutputStream,
stderrLog: OutputStream,
liveLog: OutputStream,
): Int {
val process = buildRunner.start(command, workspace, mapOf("branch" to build.runningBuild.branch))
build.process = process
if (build.cancelled.get()) {
destroyProcessTree(process)
}
val stdoutPump = pump(process.inputStream, stdoutLog, liveLog)
val stderrPump = pump(process.errorStream, stderrLog, liveLog)
try {
return process.waitFor()
} finally {
build.process = null
stdoutPump.join(PUMP_DRAIN_TIMEOUT_MILLIS)
stderrPump.join(PUMP_DRAIN_TIMEOUT_MILLIS)
}
}
/** Copies process output to both sinks as it arrives, flushing so the live log grows during the build. */
private fun pump(
input: InputStream,
vararg sinks: OutputStream,
): Thread =
thread(isDaemon = true, name = "gittally-build-log") {
val buffer = ByteArray(8192)
try {
while (true) {
val length = input.read(buffer)
if (length < 0) {
break
}
for (sink in sinks) {
synchronized(sink) {
sink.write(buffer, 0, length)
sink.flush()
}
}
}
} catch (_: IOException) {
// the stream closes when the process dies; nothing left to copy
}
}
/** TERM to all descendants and the root, wait up to 2s, then KILL survivors. */
private fun destroyProcessTree(process: Process) {
val root = process.toHandle()
val tree = root.descendants().toList() + root
tree.forEach { it.destroy() }
val deadline = System.nanoTime() + Duration.ofSeconds(2).toNanos()
while (tree.any { it.isAlive } && System.nanoTime() < deadline) {
Thread.sleep(50)
}
tree.filter { it.isAlive }.forEach { it.destroyForcibly() }
}
private fun transition(
build: ActiveBuild,
status: BuildStatus,
duration: Duration?,
): BuildResult {
val runningBuild = build.runningBuild
val updated =
repository.updateByArtifactKey(runningBuild.artifactKey) {
it.copy(status = status, duration = duration ?: it.duration)
} ?: BuildResult(
branch = runningBuild.branch,
commit = runningBuild.commit,
status = status,
startedAt = runningBuild.startedAt,
duration = duration,
artifactKey = runningBuild.artifactKey,
).also { repository.append(it) }
eventPublisher.publishEvent(BuildStatusChangedEvent(updated))
publishGiteaStatus(build, status, duration)
return updated
}
private fun publishGiteaStatus(
build: ActiveBuild,
status: BuildStatus,
duration: Duration?,
) {
try {
giteaClient.publishStatus(
sha = build.runningBuild.commit,
status = status,
description = description(status, duration),
targetUrl = null,
workingDir = build.workingDir,
)
} catch (e: Exception) {
log.warn("could not publish Gitea status {} for {}: {}", status, build.runningBuild.commit, e.message)
}
}
private fun description(
status: BuildStatus,
duration: Duration?,
): String {
val after = duration?.let { " after ${formatDuration(it)}" } ?: ""
return when (status) {
BuildStatus.PENDING -> "build queued"
BuildStatus.RUNNING -> "build running"
BuildStatus.SUCCESS -> "build succeeded$after"
BuildStatus.FAILED -> "build failed$after"
BuildStatus.INTERRUPTED -> "build interrupted$after"
BuildStatus.CANCELLED -> "build cancelled$after"
}
}
private fun formatDuration(duration: Duration): String = "%02d:%02d".format(duration.toMinutes(), duration.toSecondsPart())
private fun writeLiveLogHeader(
liveLog: OutputStream,
runningBuild: RunningBuild,
branchConfig: BranchConfig,
workspace: Path,
) {
val header =
buildString {
appendLine("building branch: ${runningBuild.branch}")
appendLine("commit: ${runningBuild.commit}")
appendLine("started: ${runningBuild.startedAt}")
appendLine("workspace: $workspace")
appendLine("build command: ${branchConfig.buildCommand}")
if (branchConfig.cleanCommand.isNotBlank()) {
appendLine("clean command: ${branchConfig.cleanCommand}")
}
appendLine()
}
synchronized(liveLog) {
liveLog.write(header.toByteArray())
liveLog.flush()
}
}
private fun appendToLiveLog(
build: ActiveBuild,
message: String,
) {
try {
Files.writeString(
build.runningBuild.liveLogFile,
message,
StandardOpenOption.CREATE,
StandardOpenOption.APPEND,
)
} catch (e: IOException) {
log.warn("could not append to live log of {}: {}", build.runningBuild.artifactKey, e.message)
}
}
private fun branchConfig(
branch: String,
workingDir: Path,
): BranchConfig {
val branches = configLoader.load(workingDir).branches
return branches[branch] ?: branches["default"] ?: BranchConfig()
}
private class ActiveBuild(
val runningBuild: RunningBuild,
val workingDir: Path,
) {
val cancelled = AtomicBoolean(false)
@Volatile
var running = false
@Volatile
var process: Process? = null
}
companion object {
/** Name of the combined live log inside the staging directory. */
const val LIVE_LOG_FILE = "build.log"
private const val CANCELLED_EXIT_CODE = 130
private const val PUMP_DRAIN_TIMEOUT_MILLIS = 10_000L
}
}
@@ -0,0 +1,13 @@
package de.hoennig.gittally.build
import java.time.Duration
import java.time.Instant
data class BuildResult(
val branch: String,
val commit: String,
val status: BuildStatus,
val startedAt: Instant,
val duration: Duration? = null,
val artifactKey: String,
)
@@ -0,0 +1,43 @@
package de.hoennig.gittally.build
interface BuildResultRepository {
fun append(result: BuildResult)
/** Applies [transform] to the newest entry of [branch]; returns null if the branch has no entries. */
fun updateLatest(
branch: String,
transform: (BuildResult) -> BuildResult,
): BuildResult?
/** Applies [transform] to the entry with [artifactKey]; returns null if no entry matches. */
fun updateByArtifactKey(
artifactKey: String,
transform: (BuildResult) -> BuildResult,
): BuildResult?
fun latestFor(branch: String): BuildResult?
/** The newest entry of each branch, newest first. */
fun latestPerBranch(): List<BuildResult>
/** All entries, newest first. */
fun history(): List<BuildResult>
/** Removes all entries with the given artifact key; returns true if anything was removed. */
fun delete(artifactKey: String): Boolean
/**
* Startup recovery: RUNNING entries and PENDING entries superseded by a newer entry
* of the same branch become INTERRUPTED. Returns the changed entries.
*/
fun markStaleRunningAsInterrupted(): List<BuildResult>
/**
* Keeps the newest [retentionPerBranch] entries per branch and drops entries of branches
* not contained in [originBranches]. Returns the removed entries.
*/
fun prune(
originBranches: Collection<String>,
retentionPerBranch: Int,
): List<BuildResult>
}
@@ -0,0 +1,31 @@
package de.hoennig.gittally.build
import org.springframework.stereotype.Component
import java.nio.file.Path
/**
* Starts a single build or clean command and hands the [Process] back to the caller,
* which owns log streaming and process-tree termination.
* Native shell execution for now; a Docker runner can plug in later (step 11).
*/
interface BuildRunner {
fun start(
command: String,
workingDir: Path,
environment: Map<String, String>,
): Process
}
@Component
class ProcessBuildRunner : BuildRunner {
override fun start(
command: String,
workingDir: Path,
environment: Map<String, String>,
): Process {
val processBuilder = ProcessBuilder("bash", "-c", command)
processBuilder.directory(workingDir.toFile())
processBuilder.environment().putAll(environment)
return processBuilder.start()
}
}
@@ -0,0 +1,17 @@
package de.hoennig.gittally.build
enum class BuildStatus {
PENDING,
RUNNING,
SUCCESS,
FAILED,
INTERRUPTED,
CANCELLED,
;
val isTerminal: Boolean
get() = this != PENDING && this != RUNNING
val isRestartable: Boolean
get() = this == PENDING || this == RUNNING || this == INTERRUPTED
}
@@ -0,0 +1,185 @@
package de.hoennig.gittally.build
import com.fasterxml.jackson.databind.DeserializationFeature
import com.fasterxml.jackson.databind.ObjectMapper
import com.fasterxml.jackson.databind.SerializationFeature
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule
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
/**
* Stores build results as a JSON file, e.g. `.git/gittally/build-results.json`.
* Writes are atomic (temp file + atomic move) so readers never see partial content.
*/
class FileBuildResultRepository(
private val file: Path,
) : BuildResultRepository {
private val log = LoggerFactory.getLogger(FileBuildResultRepository::class.java)
private val lock = Any()
private val json =
ObjectMapper()
.registerKotlinModule()
.registerModule(JavaTimeModule())
.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)
.configure(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS, false)
.configure(SerializationFeature.WRITE_DURATIONS_AS_TIMESTAMPS, false)
.configure(SerializationFeature.INDENT_OUTPUT, true)
override fun append(result: BuildResult) {
synchronized(lock) {
save(load() + result)
}
}
override fun updateLatest(
branch: String,
transform: (BuildResult) -> BuildResult,
): BuildResult? {
synchronized(lock) {
val results = load()
val index = indexOfLatest(results, branch) ?: return null
val updated = transform(results[index])
save(results.toMutableList().also { it[index] = updated })
return updated
}
}
override fun updateByArtifactKey(
artifactKey: String,
transform: (BuildResult) -> BuildResult,
): BuildResult? {
synchronized(lock) {
val results = load()
val index = results.indexOfLast { it.artifactKey == artifactKey }
if (index < 0) {
return null
}
val updated = transform(results[index])
save(results.toMutableList().also { it[index] = updated })
return updated
}
}
override fun latestFor(branch: String): BuildResult? {
val results = load()
return indexOfLatest(results, branch)?.let { results[it] }
}
override fun latestPerBranch(): List<BuildResult> =
load()
.groupBy { it.branch }
.values
.map { entries -> entries.reduce(::laterOf) }
.sortedByDescending { it.startedAt }
override fun history(): List<BuildResult> = load().sortedByDescending { it.startedAt }
override fun delete(artifactKey: String): Boolean {
synchronized(lock) {
val results = load()
val remaining = results.filterNot { it.artifactKey == artifactKey }
if (remaining.size == results.size) {
return false
}
save(remaining)
return true
}
}
override fun markStaleRunningAsInterrupted(): List<BuildResult> {
synchronized(lock) {
val results = load()
val changed = mutableListOf<BuildResult>()
val updated =
results.map { result ->
val superseded =
results.any { it.branch == result.branch && it.startedAt.isAfter(result.startedAt) }
if (result.status == BuildStatus.RUNNING ||
(result.status == BuildStatus.PENDING && superseded)
) {
result.copy(status = BuildStatus.INTERRUPTED).also { changed += it }
} else {
result
}
}
if (changed.isNotEmpty()) {
save(updated)
}
return changed
}
}
override fun prune(
originBranches: Collection<String>,
retentionPerBranch: Int,
): List<BuildResult> {
synchronized(lock) {
val results = load()
val originBranchSet = originBranches.toSet()
val kept =
results
.filter { it.branch in originBranchSet }
.groupBy { it.branch }
.values
.flatMap { entries ->
entries
.sortedByDescending { it.startedAt }
.take(retentionPerBranch.coerceAtLeast(0))
}.toSet()
val removed = results.filterNot { it in kept }
if (removed.isNotEmpty()) {
save(results.filter { it in kept })
}
return removed
}
}
/** The index of the newest entry of [branch]; on equal timestamps the later appended entry wins. */
private fun indexOfLatest(
results: List<BuildResult>,
branch: String,
): Int? {
var latest: Int? = null
results.forEachIndexed { index, result ->
if (result.branch == branch &&
(latest == null || !result.startedAt.isBefore(results[latest].startedAt))
) {
latest = index
}
}
return latest
}
private fun laterOf(
first: BuildResult,
second: BuildResult,
): BuildResult = if (second.startedAt.isBefore(first.startedAt)) first else second
private fun load(): List<BuildResult> {
if (!Files.exists(file)) {
return emptyList()
}
return try {
json.readValue<List<BuildResult>>(file.toFile())
} catch (e: Exception) {
log.warn("ignoring unreadable build results file {}: {}", file, e.message)
emptyList()
}
}
private fun save(results: List<BuildResult>) {
Files.createDirectories(file.parent)
val tempFile = Files.createTempFile(file.parent, file.fileName.toString(), ".tmp")
try {
json.writeValue(tempFile.toFile(), results)
Files.move(tempFile, file, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING)
} finally {
Files.deleteIfExists(tempFile)
}
}
}
@@ -0,0 +1,21 @@
package de.hoennig.gittally.build
import java.nio.file.Path
import java.time.Instant
/** Handle to a build accepted by the [BuildExecutor]; log paths become valid once the build runs. */
data class RunningBuild(
val branch: String,
val commit: String,
val artifactKey: String,
val startedAt: Instant,
/** Working directory for build output; handed to the [ArtifactStore] when the build ends. */
val stagingDir: Path,
/** Combined stdout+stderr log, written live while the build runs. */
val liveLogFile: Path,
)
/** Published via Spring's `ApplicationEventPublisher` on every persisted status transition. */
data class BuildStatusChangedEvent(
val result: BuildResult,
)
@@ -112,6 +112,11 @@ class InitCommand(
repo: ${detected.repo} # repository name
statusContext: GitTally # label shown on Gitea commit status checks (default: GitTally)
# Build execution.
builds:
# how many branches may build at the same time (at most one build per branch regardless)
maxConcurrent: 1
# Build artifact retention.
artifacts:
# number of builds to keep per branch
@@ -4,6 +4,7 @@ data class GitTallyConfig(
val server: ServerConfig = ServerConfig(),
val git: GitConfig = GitConfig(),
val gitea: GiteaConfig = GiteaConfig(),
val builds: BuildsConfig = BuildsConfig(),
val artifacts: ArtifactsConfig = ArtifactsConfig(),
val watcher: WatcherConfig = WatcherConfig(),
val branches: Map<String, BranchConfig> = mapOf("default" to BranchConfig()),
@@ -25,6 +26,11 @@ data class GiteaConfig(
val statusContext: String = "GitTally",
)
data class BuildsConfig(
/** How many branches may build at the same time; at most one build per branch regardless. */
val maxConcurrent: Int = 1,
)
data class ArtifactsConfig(
val retentionPerBranch: Int = 3,
)
@@ -157,6 +157,28 @@ class GitService(
.stdout
.trim()
/** Creates a worktree at [path] with [commit] checked out as a detached HEAD; [path] must not exist yet. */
fun worktreeAdd(
path: Path,
commit: String,
workingDir: Path = Paths.get("."),
) {
runner.runOrThrow(listOf("git", "worktree", "add", "--detach", path.toString(), commit), workingDir)
}
/** Removes registrations of worktrees whose directories no longer exist. */
fun worktreePrune(workingDir: Path = Paths.get(".")) {
runner.runOrThrow(listOf("git", "worktree", "prune"), workingDir)
}
/** Checks out [commit] as a detached HEAD, discarding local modifications to tracked files. */
fun checkoutDetached(
commit: String,
workingDir: Path = Paths.get("."),
) {
runner.runOrThrow(listOf("git", "checkout", "--force", "--detach", commit), workingDir)
}
private fun refExists(
ref: String,
workingDir: Path,
@@ -0,0 +1,38 @@
package de.hoennig.gittally.build
import io.kotest.core.spec.style.FunSpec
import io.kotest.matchers.shouldBe
import io.kotest.matchers.shouldNotBe
import io.kotest.matchers.string.shouldContain
import io.kotest.matchers.string.shouldMatch
import java.time.Instant
class ArtifactKeysTest : FunSpec() {
private val startedAt = Instant.parse("2026-07-07T10:00:00Z")
init {
test("branchKey sanitizes unsafe characters and appends a 12-char hash") {
ArtifactKeys.branchKey("feature/x") shouldMatch "feature_x-[0-9a-f]{12}"
}
test("branches with the same sanitized name get different keys") {
ArtifactKeys.branchKey("feature/x") shouldNotBe ArtifactKeys.branchKey("feature_x")
}
test("buildKey is stable for the same input") {
ArtifactKeys.buildKey("main", startedAt) shouldBe ArtifactKeys.buildKey("main", startedAt)
}
test("buildKey differs per start time") {
ArtifactKeys.buildKey("main", startedAt) shouldNotBe
ArtifactKeys.buildKey("main", startedAt.plusSeconds(1))
}
test("buildKey contains the branch key and the sanitized start timestamp") {
val key = ArtifactKeys.buildKey("main", startedAt)
key shouldContain ArtifactKeys.branchKey("main")
key shouldContain "2026-07-07T10_00_00Z"
}
}
}
@@ -0,0 +1,326 @@
package de.hoennig.gittally.build
import de.hoennig.gittally.config.ConfigLoader
import de.hoennig.gittally.gitea.GiteaClient
import io.kotest.assertions.nondeterministic.eventually
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.shouldContain
import io.kotest.matchers.collections.shouldContainExactly
import io.kotest.matchers.collections.shouldContainExactlyInAnyOrder
import io.kotest.matchers.ints.shouldBeGreaterThan
import io.kotest.matchers.nulls.shouldNotBeNull
import io.kotest.matchers.shouldBe
import io.kotest.matchers.shouldNotBe
import io.kotest.matchers.string.shouldContain
import io.kotest.matchers.string.shouldNotContain
import io.mockk.every
import io.mockk.mockk
import io.mockk.verify
import org.springframework.context.ApplicationEventPublisher
import java.nio.file.Files
import java.nio.file.Path
import java.util.concurrent.CopyOnWriteArrayList
import kotlin.time.Duration.Companion.seconds
class BuildExecutorTest : FunSpec() {
private class Harness(
configYaml: String,
workspaceSubdir: String? = null,
) {
val workingDir: Path = Files.createTempDirectory("gittally-executor-test")
val repository = FileBuildResultRepository(workingDir.resolve("build-results.json"))
val giteaClient = mockk<GiteaClient>(relaxed = true)
val artifactStore = mockk<ArtifactStore>(relaxed = true)
val events = CopyOnWriteArrayList<BuildStatusChangedEvent>()
val workspaceCalls = CopyOnWriteArrayList<Pair<String, String>>()
val workspaces =
BranchWorkspaces { branch, commit, _ ->
workspaceCalls += branch to commit
if (workspaceSubdir == null) {
workingDir
} else {
Files.createDirectories(workingDir.resolve(workspaceSubdir))
}
}
val executor =
BuildExecutor(
repository = repository,
configLoader = ConfigLoader(),
giteaClient = giteaClient,
buildRunner = ProcessBuildRunner(),
workspaces = workspaces,
artifactStore = artifactStore,
eventPublisher =
ApplicationEventPublisher { event ->
if (event is BuildStatusChangedEvent) {
events += event
}
},
)
init {
Files.writeString(workingDir.resolve(".gittally.yml"), configYaml)
}
}
private fun harness(
buildCommand: String,
cleanCommand: String = "",
maxConcurrent: Int = 1,
workspaceSubdir: String? = null,
) = Harness(
"""
builds:
maxConcurrent: $maxConcurrent
branches:
default:
buildCommand: "$buildCommand"
cleanCommand: "$cleanCommand"
""".trimIndent(),
workspaceSubdir = workspaceSubdir,
)
private suspend fun awaitStatus(
harness: Harness,
branch: String,
status: BuildStatus,
) {
eventually(30.seconds) {
harness.repository.latestFor(branch)?.status shouldBe status
}
}
private suspend fun awaitIdle(harness: Harness) {
eventually(30.seconds) {
harness.executor.currentBuilds().shouldBeEmpty()
}
}
init {
test("a successful build transitions pending, running, success and captures all logs") {
val h =
harness(
buildCommand = "echo out-\$branch; echo err-\$branch 1>&2",
cleanCommand = "echo clean-\$branch",
)
val build = h.executor.startBuild("main", "abc123", h.workingDir)
awaitStatus(h, "main", BuildStatus.SUCCESS)
awaitIdle(h)
val result = h.repository.latestFor("main").shouldNotBeNull()
result.artifactKey shouldBe build.artifactKey
result.duration shouldNotBe null
h.events.map { it.result.status } shouldContainExactly
listOf(BuildStatus.PENDING, BuildStatus.RUNNING, BuildStatus.SUCCESS)
h.workspaceCalls shouldContain ("main" to "abc123")
val stdoutLog = Files.readString(build.stagingDir.resolve("build.stdout.log"))
stdoutLog shouldContain "clean-main"
stdoutLog shouldContain "out-main"
Files.readString(build.stagingDir.resolve("build.stderr.log")) shouldContain "err-main"
val liveLog = Files.readString(build.liveLogFile)
liveLog shouldContain "clean-main"
liveLog shouldContain "out-main"
liveLog shouldContain "err-main"
verify { h.giteaClient.publishStatus("abc123", BuildStatus.PENDING, any(), null, h.workingDir) }
verify { h.giteaClient.publishStatus("abc123", BuildStatus.RUNNING, any(), null, h.workingDir) }
verify { h.giteaClient.publishStatus("abc123", BuildStatus.SUCCESS, any(), null, h.workingDir) }
verify { h.artifactStore.persist(match { it.status == BuildStatus.SUCCESS }, build.stagingDir) }
}
test("build commands run in the workspace prepared for the branch") {
val h = harness(buildCommand = "pwd", workspaceSubdir = "branch-workspace")
val build = h.executor.startBuild("main", "abc123", h.workingDir)
awaitStatus(h, "main", BuildStatus.SUCCESS)
awaitIdle(h)
Files.readString(build.stagingDir.resolve("build.stdout.log")) shouldContain "branch-workspace"
}
test("the repository reports RUNNING while the build sleeps") {
val h = harness("sleep 10")
val build = h.executor.startBuild("main", "abc123", h.workingDir)
eventually(10.seconds) {
h.repository.latestFor("main")?.status shouldBe BuildStatus.RUNNING
}
h.executor.currentBuilds().map { it.artifactKey } shouldContainExactly listOf(build.artifactKey)
h.executor.cancel(build.artifactKey).shouldBeTrue()
awaitStatus(h, "main", BuildStatus.CANCELLED)
}
test("a failing build command records FAILED with a duration") {
val h = harness("exit 3")
h.executor.startBuild("main", "abc123", h.workingDir)
awaitStatus(h, "main", BuildStatus.FAILED)
awaitIdle(h)
h.repository.latestFor("main")?.duration shouldNotBe null
h.events.map { it.result.status } shouldContainExactly
listOf(BuildStatus.PENDING, BuildStatus.RUNNING, BuildStatus.FAILED)
}
test("a failing clean command fails the build without running the build command") {
val h = harness(buildCommand = "echo forbidden-\$branch", cleanCommand = "exit 1")
val build = h.executor.startBuild("main", "abc123", h.workingDir)
awaitStatus(h, "main", BuildStatus.FAILED)
awaitIdle(h)
Files.readString(build.stagingDir.resolve("build.stdout.log")) shouldNotContain "forbidden-main"
Files.readString(build.liveLogFile) shouldNotContain "forbidden-main"
}
test("cancel kills a sleeping process tree and records CANCELLED") {
val h = harness("echo \$\$ > pid-file; sleep 30 & sleep 30 & wait")
val build = h.executor.startBuild("main", "abc123", h.workingDir)
lateinit var root: ProcessHandle
var children = emptyList<ProcessHandle>()
eventually(10.seconds) {
val pid =
Files
.readString(h.workingDir.resolve("pid-file"))
.trim()
.toLong()
root = ProcessHandle.of(pid).orElseThrow()
children = root.descendants().toList()
children.size shouldBe 2
}
h.executor.cancel(build.artifactKey).shouldBeTrue()
awaitStatus(h, "main", BuildStatus.CANCELLED)
h.repository.latestFor("main")?.duration shouldNotBe null
eventually(10.seconds) {
root.isAlive shouldBe false
children.forEach { it.isAlive shouldBe false }
}
}
test("cancel with an unknown artifact key returns false") {
val h = harness("echo ok")
h.executor.cancel("unknown-key").shouldBeFalse()
}
test("the live log grows while the build is still running") {
val h = harness("echo one-\$branch; sleep 3; echo two-\$branch")
val build = h.executor.startBuild("main", "abc123", h.workingDir)
eventually(10.seconds) {
Files.readString(build.liveLogFile) shouldContain "one-main"
}
h.repository.latestFor("main")?.status shouldBe BuildStatus.RUNNING
awaitStatus(h, "main", BuildStatus.SUCCESS)
Files.readString(build.liveLogFile) shouldContain "two-main"
}
test("a Gitea failure does not fail the build") {
val h = harness("echo ok")
every {
h.giteaClient.publishStatus(any(), any(), any(), any(), any())
} throws RuntimeException("gitea down")
h.executor.startBuild("main", "abc123", h.workingDir)
awaitStatus(h, "main", BuildStatus.SUCCESS)
}
test("with maxConcurrent 1 a second branch stays PENDING until the first finished") {
val h =
Harness(
"""
builds:
maxConcurrent: 1
branches:
branch-a:
buildCommand: "sleep 1"
cleanCommand: ""
branch-b:
buildCommand: "echo ok"
cleanCommand: ""
""".trimIndent(),
)
h.executor.startBuild("branch-a", "sha-a", h.workingDir)
h.executor.startBuild("branch-b", "sha-b", h.workingDir)
h.repository.latestFor("branch-b")?.status shouldBe BuildStatus.PENDING
awaitStatus(h, "branch-b", BuildStatus.SUCCESS)
awaitStatus(h, "branch-a", BuildStatus.SUCCESS)
val transitions = h.events.map { it.result.branch to it.result.status }
transitions.indexOf("branch-b" to BuildStatus.RUNNING) shouldBeGreaterThan
transitions.indexOf("branch-a" to BuildStatus.SUCCESS)
}
test("with maxConcurrent 2 two branches build at the same time") {
val h = harness("sleep 10", maxConcurrent = 2)
val buildA = h.executor.startBuild("branch-a", "sha-a", h.workingDir)
val buildB = h.executor.startBuild("branch-b", "sha-b", h.workingDir)
eventually(10.seconds) {
h.repository.latestFor("branch-a")?.status shouldBe BuildStatus.RUNNING
h.repository.latestFor("branch-b")?.status shouldBe BuildStatus.RUNNING
}
h.executor.currentBuilds().map { it.artifactKey } shouldContainExactlyInAnyOrder
listOf(buildA.artifactKey, buildB.artifactKey)
h.executor.cancel(buildA.artifactKey).shouldBeTrue()
h.executor.cancel(buildB.artifactKey).shouldBeTrue()
awaitStatus(h, "branch-a", BuildStatus.CANCELLED)
awaitStatus(h, "branch-b", BuildStatus.CANCELLED)
}
test("a second build of the same branch waits even when a slot is free") {
val h = harness("sleep 1", maxConcurrent = 2)
val first = h.executor.startBuild("main", "sha-1", h.workingDir)
val second = h.executor.startBuild("main", "sha-2", h.workingDir)
eventually(30.seconds) {
h.repository
.history()
.map { it.status }
.toSet() shouldBe setOf(BuildStatus.SUCCESS)
}
val transitions = h.events.map { it.result.artifactKey to it.result.status }
transitions.indexOf(second.artifactKey to BuildStatus.RUNNING) shouldBeGreaterThan
transitions.indexOf(first.artifactKey to BuildStatus.SUCCESS)
}
test("cancel only affects the addressed build, other branches keep running") {
val h = harness("sleep 10", maxConcurrent = 2)
val buildA = h.executor.startBuild("branch-a", "sha-a", h.workingDir)
val buildB = h.executor.startBuild("branch-b", "sha-b", h.workingDir)
eventually(10.seconds) {
h.repository.latestFor("branch-a")?.status shouldBe BuildStatus.RUNNING
h.repository.latestFor("branch-b")?.status shouldBe BuildStatus.RUNNING
}
h.executor.cancel(buildA.artifactKey).shouldBeTrue()
awaitStatus(h, "branch-a", BuildStatus.CANCELLED)
h.repository.latestFor("branch-b")?.status shouldBe BuildStatus.RUNNING
h.executor.currentBuilds().map { it.artifactKey } shouldContainExactly listOf(buildB.artifactKey)
h.executor.cancel(buildB.artifactKey).shouldBeTrue()
awaitStatus(h, "branch-b", BuildStatus.CANCELLED)
}
}
}
@@ -0,0 +1,18 @@
package de.hoennig.gittally.build
import io.kotest.core.spec.style.FunSpec
import io.kotest.matchers.shouldBe
class BuildStatusTest : FunSpec() {
init {
test("terminal statuses are all but pending and running") {
BuildStatus.entries.filter { it.isTerminal } shouldBe
listOf(BuildStatus.SUCCESS, BuildStatus.FAILED, BuildStatus.INTERRUPTED, BuildStatus.CANCELLED)
}
test("restartable statuses are pending, running, and interrupted") {
BuildStatus.entries.filter { it.isRestartable } shouldBe
listOf(BuildStatus.PENDING, BuildStatus.RUNNING, BuildStatus.INTERRUPTED)
}
}
}
@@ -0,0 +1,244 @@
package de.hoennig.gittally.build
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.shouldBe
import java.nio.file.Files
import java.nio.file.Path
import java.time.Duration
import java.time.Instant
class FileBuildResultRepositoryTest : FunSpec() {
private val baseTime = Instant.parse("2026-07-07T10:00:00Z")
private fun newFile(): Path = Files.createTempDirectory("gittally-results-test").resolve("build-results.json")
private fun result(
branch: String = "main",
status: BuildStatus = BuildStatus.SUCCESS,
startedOffsetSeconds: Long = 0,
commit: String = "abc1234",
duration: Duration? = Duration.ofSeconds(90),
artifactKey: String = "$branch-$startedOffsetSeconds",
) = BuildResult(
branch = branch,
commit = commit,
status = status,
startedAt = baseTime.plusSeconds(startedOffsetSeconds),
duration = duration,
artifactKey = artifactKey,
)
init {
test("starts empty when file is missing") {
val repository = FileBuildResultRepository(newFile())
repository.history().shouldBeEmpty()
repository.latestPerBranch().shouldBeEmpty()
repository.latestFor("main").shouldBeNull()
}
test("append and reload round-trips all fields") {
val file = newFile()
val original = result(branch = "feature/x", status = BuildStatus.FAILED, duration = Duration.ofSeconds(61))
FileBuildResultRepository(file).append(original)
val reloaded = FileBuildResultRepository(file).history()
reloaded shouldContainExactly listOf(original)
}
test("round-trips a null duration") {
val file = newFile()
val original = result(status = BuildStatus.PENDING, duration = null)
FileBuildResultRepository(file).append(original)
FileBuildResultRepository(file).history() shouldContainExactly listOf(original)
}
test("history returns newest first") {
val repository = FileBuildResultRepository(newFile())
val older = result(startedOffsetSeconds = 0)
val newer = result(startedOffsetSeconds = 60)
repository.append(older)
repository.append(newer)
repository.history() shouldContainExactly listOf(newer, older)
}
test("latestFor returns the newest entry of the branch") {
val repository = FileBuildResultRepository(newFile())
repository.append(result(branch = "main", startedOffsetSeconds = 0))
repository.append(result(branch = "main", startedOffsetSeconds = 60))
repository.append(result(branch = "other", startedOffsetSeconds = 120))
repository.latestFor("main") shouldBe result(branch = "main", startedOffsetSeconds = 60)
}
test("latestPerBranch returns one entry per branch, 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
listOf(
result(branch = "feature/x", startedOffsetSeconds = 120),
result(branch = "main", startedOffsetSeconds = 60),
)
}
test("updateLatest transforms only the newest entry of the branch") {
val repository = FileBuildResultRepository(newFile())
repository.append(result(branch = "main", status = BuildStatus.SUCCESS, startedOffsetSeconds = 0))
repository.append(result(branch = "main", status = BuildStatus.RUNNING, startedOffsetSeconds = 60))
val updated = repository.updateLatest("main") { it.copy(status = BuildStatus.SUCCESS) }
updated shouldBe result(branch = "main", status = BuildStatus.SUCCESS, startedOffsetSeconds = 60)
repository.history() shouldContainExactly
listOf(
result(branch = "main", status = BuildStatus.SUCCESS, startedOffsetSeconds = 60),
result(branch = "main", status = BuildStatus.SUCCESS, startedOffsetSeconds = 0),
)
}
test("updateByArtifactKey updates the matching entry even when a newer entry of the branch exists") {
val repository = FileBuildResultRepository(newFile())
repository.append(result(branch = "main", status = BuildStatus.RUNNING, startedOffsetSeconds = 0, artifactKey = "key-a"))
repository.append(result(branch = "main", status = BuildStatus.PENDING, startedOffsetSeconds = 60, artifactKey = "key-b"))
val updated = repository.updateByArtifactKey("key-a") { it.copy(status = BuildStatus.SUCCESS) }
updated shouldBe result(branch = "main", status = BuildStatus.SUCCESS, startedOffsetSeconds = 0, artifactKey = "key-a")
repository.latestFor("main") shouldBe
result(branch = "main", status = BuildStatus.PENDING, startedOffsetSeconds = 60, artifactKey = "key-b")
}
test("updateByArtifactKey returns null for an unknown artifact key") {
val repository = FileBuildResultRepository(newFile())
repository.append(result())
repository.updateByArtifactKey("unknown") { it.copy(status = BuildStatus.FAILED) }.shouldBeNull()
}
test("updateLatest returns null for an unknown branch") {
val repository = FileBuildResultRepository(newFile())
repository.append(result(branch = "main"))
repository.updateLatest("unknown") { it.copy(status = BuildStatus.FAILED) }.shouldBeNull()
}
test("delete removes entries by artifact key") {
val repository = FileBuildResultRepository(newFile())
repository.append(result(branch = "main", startedOffsetSeconds = 0, artifactKey = "key-a"))
repository.append(result(branch = "main", startedOffsetSeconds = 60, artifactKey = "key-b"))
repository.delete("key-a").shouldBeTrue()
repository.history() shouldContainExactly
listOf(result(branch = "main", startedOffsetSeconds = 60, artifactKey = "key-b"))
}
test("delete returns false for an unknown artifact key") {
val repository = FileBuildResultRepository(newFile())
repository.append(result())
repository.delete("unknown").shouldBeFalse()
}
test("markStaleRunningAsInterrupted marks running builds") {
val repository = FileBuildResultRepository(newFile())
repository.append(result(branch = "main", status = BuildStatus.RUNNING))
val changed = repository.markStaleRunningAsInterrupted()
changed shouldContainExactly listOf(result(branch = "main", status = BuildStatus.INTERRUPTED))
repository.latestFor("main")?.status shouldBe BuildStatus.INTERRUPTED
}
test("markStaleRunningAsInterrupted marks superseded pending builds") {
val repository = FileBuildResultRepository(newFile())
repository.append(result(branch = "main", status = BuildStatus.PENDING, startedOffsetSeconds = 0))
repository.append(result(branch = "main", status = BuildStatus.PENDING, startedOffsetSeconds = 60))
val changed = repository.markStaleRunningAsInterrupted()
changed shouldContainExactly
listOf(result(branch = "main", status = BuildStatus.INTERRUPTED, startedOffsetSeconds = 0))
repository.latestFor("main")?.status shouldBe BuildStatus.PENDING
}
test("markStaleRunningAsInterrupted keeps terminal statuses and unrelated branches") {
val repository = FileBuildResultRepository(newFile())
repository.append(result(branch = "main", status = BuildStatus.SUCCESS, startedOffsetSeconds = 0))
repository.append(result(branch = "other", status = BuildStatus.FAILED, startedOffsetSeconds = 60))
repository.markStaleRunningAsInterrupted().shouldBeEmpty()
repository.history().map { it.status } shouldContainExactly
listOf(BuildStatus.FAILED, BuildStatus.SUCCESS)
}
test("prune keeps only the retention count per branch and returns the removed entries") {
val repository = FileBuildResultRepository(newFile())
repository.append(result(branch = "main", startedOffsetSeconds = 0))
repository.append(result(branch = "main", startedOffsetSeconds = 60))
repository.append(result(branch = "main", startedOffsetSeconds = 120))
val removed = repository.prune(originBranches = listOf("main"), retentionPerBranch = 2)
removed shouldContainExactly listOf(result(branch = "main", startedOffsetSeconds = 0))
repository.history() shouldContainExactly
listOf(
result(branch = "main", startedOffsetSeconds = 120),
result(branch = "main", startedOffsetSeconds = 60),
)
}
test("prune drops entries of branches missing from origin") {
val repository = FileBuildResultRepository(newFile())
repository.append(result(branch = "main", startedOffsetSeconds = 0))
repository.append(result(branch = "gone", startedOffsetSeconds = 60))
repository.append(result(branch = "gone", startedOffsetSeconds = 120))
val removed = repository.prune(originBranches = listOf("main"), retentionPerBranch = 3)
removed shouldContainExactlyInAnyOrder
listOf(
result(branch = "gone", startedOffsetSeconds = 60),
result(branch = "gone", startedOffsetSeconds = 120),
)
repository.history() shouldContainExactly listOf(result(branch = "main", startedOffsetSeconds = 0))
}
test("a corrupt file is treated as empty and can be overwritten") {
val file = newFile()
Files.createDirectories(file.parent)
Files.writeString(file, "this is not json {")
val repository = FileBuildResultRepository(file)
repository.history().shouldBeEmpty()
repository.append(result())
repository.history() shouldContainExactly listOf(result())
}
test("writes leave no temp files behind") {
val file = newFile()
val repository = FileBuildResultRepository(file)
repository.append(result())
repository.updateLatest("main") { it.copy(status = BuildStatus.FAILED) }
Files.list(file.parent).use { entries ->
entries.toList().map { it.fileName.toString() } shouldContainExactly listOf("build-results.json")
}
}
}
}
@@ -0,0 +1,126 @@
package de.hoennig.gittally.build
import de.hoennig.gittally.config.ConfigLoader
import de.hoennig.gittally.git.GitCommandRunner
import de.hoennig.gittally.git.GitService
import io.kotest.core.spec.style.FunSpec
import io.kotest.matchers.nulls.shouldBeNull
import io.kotest.matchers.shouldBe
import io.kotest.matchers.shouldNotBe
import io.kotest.matchers.string.shouldStartWith
import java.nio.file.Files
import java.nio.file.Path
/** Integration tests against a local fixture repository; no network access needed. */
class GitWorktreeWorkspacesTest : FunSpec() {
private val runner = GitCommandRunner()
private val gitService = GitService(runner, ConfigLoader())
private val workspaces = GitWorktreeWorkspaces(gitService)
// hermetic git: fixed identity, no user/system config (hooks, gpg signing, ...)
private val gitEnvironment =
mapOf(
"GIT_AUTHOR_NAME" to "GitTally Test",
"GIT_AUTHOR_EMAIL" to "test@example.com",
"GIT_COMMITTER_NAME" to "GitTally Test",
"GIT_COMMITTER_EMAIL" to "test@example.com",
"GIT_CONFIG_GLOBAL" to "/dev/null",
"GIT_CONFIG_SYSTEM" to "/dev/null",
)
private inner class Fixture {
val repo: Path = Files.createTempDirectory("gittally-workspaces-test").resolve("repo")
init {
Files.createDirectories(repo)
git("init", "-b", "main", ".")
commitFile("README.md", "hello")
}
fun git(vararg args: String) {
runner.runOrThrow(listOf("git") + args, repo, gitEnvironment)
}
fun commitFile(
name: String,
content: String,
): String {
Files.writeString(repo.resolve(name), content)
git("add", name)
git("commit", "-m", "add $name")
return gitService.headCommit(repo)
}
}
init {
test("creates a per-branch worktree with the commit checked out detached") {
val fixture = Fixture()
val commit = gitService.headCommit(fixture.repo)
val workspace = workspaces.prepare("main", commit, fixture.repo)
workspace.toString() shouldStartWith
fixture.repo.resolve(GitWorktreeWorkspaces.WORKTREES_DIR).toString()
workspace.fileName.toString() shouldBe ArtifactKeys.branchKey("main")
Files.readString(workspace.resolve("README.md")) shouldBe "hello"
gitService.headCommit(workspace) shouldBe commit
gitService.currentBranch(workspace).shouldBeNull()
}
test("reuses the worktree and switches it to a newer commit") {
val fixture = Fixture()
val firstCommit = gitService.headCommit(fixture.repo)
val firstWorkspace = workspaces.prepare("main", firstCommit, fixture.repo)
val secondCommit = fixture.commitFile("second.txt", "second")
val secondWorkspace = workspaces.prepare("main", secondCommit, fixture.repo)
secondWorkspace shouldBe firstWorkspace
gitService.headCommit(secondWorkspace) shouldBe secondCommit
Files.readString(secondWorkspace.resolve("second.txt")) shouldBe "second"
}
test("recreates a workspace whose directory was deleted") {
val fixture = Fixture()
val commit = gitService.headCommit(fixture.repo)
val workspace = workspaces.prepare("main", commit, fixture.repo)
workspace.toFile().deleteRecursively()
val recreated = workspaces.prepare("main", commit, fixture.repo)
recreated shouldBe workspace
gitService.headCommit(recreated) shouldBe commit
}
test("replaces a broken workspace directory that is not a worktree") {
val fixture = Fixture()
val commit = gitService.headCommit(fixture.repo)
val workspace = fixture.repo.resolve(GitWorktreeWorkspaces.WORKTREES_DIR).resolve(ArtifactKeys.branchKey("main"))
Files.createDirectories(workspace)
Files.writeString(workspace.resolve("junk.txt"), "junk")
val prepared = workspaces.prepare("main", commit, fixture.repo)
prepared shouldBe workspace
gitService.headCommit(prepared) shouldBe commit
Files.exists(prepared.resolve("junk.txt")) shouldBe false
}
test("different branches get different workspaces") {
val fixture = Fixture()
val mainCommit = gitService.headCommit(fixture.repo)
fixture.git("switch", "-c", "feature/x")
val featureCommit = fixture.commitFile("feature.txt", "feature")
fixture.git("switch", "main")
val mainWorkspace = workspaces.prepare("main", mainCommit, fixture.repo)
val featureWorkspace = workspaces.prepare("feature/x", featureCommit, fixture.repo)
featureWorkspace shouldNotBe mainWorkspace
gitService.headCommit(mainWorkspace) shouldBe mainCommit
gitService.headCommit(featureWorkspace) shouldBe featureCommit
Files.exists(mainWorkspace.resolve("feature.txt")) shouldBe false
Files.readString(featureWorkspace.resolve("feature.txt")) shouldBe "feature"
}
}
}
@@ -0,0 +1,48 @@
package de.hoennig.gittally.build
import io.kotest.core.spec.style.FunSpec
import io.kotest.matchers.shouldBe
import io.kotest.matchers.string.shouldContain
import java.nio.file.Files
import java.nio.file.Path
class ProcessBuildRunnerTest : FunSpec() {
private val runner = ProcessBuildRunner()
private fun tempDir(): Path = Files.createTempDirectory("gittally-runner-test")
init {
test("propagates the exit code") {
val process = runner.start("exit 7", tempDir(), emptyMap())
process.waitFor() shouldBe 7
}
test("passes the environment to the command") {
val process = runner.start("echo value=\$branch", tempDir(), mapOf("branch" to "feature/x"))
process.inputStream.readAllBytes().decodeToString() shouldContain "value=feature/x"
process.waitFor() shouldBe 0
}
test("runs in the given working directory") {
val dir = tempDir()
val process = runner.start("pwd", dir, emptyMap())
process.inputStream
.readAllBytes()
.decodeToString()
.trim() shouldBe dir.toRealPath().toString()
process.waitFor() shouldBe 0
}
test("keeps stdout and stderr separate") {
val process = runner.start("echo to-stdout; echo to-stderr 1>&2", tempDir(), emptyMap())
process.inputStream.readAllBytes().decodeToString() shouldContain "to-stdout"
process.errorStream.readAllBytes().decodeToString() shouldContain "to-stderr"
process.waitFor() shouldBe 0
}
}
}
@@ -34,6 +34,19 @@ class ConfigLoaderTest : FunSpec() {
config.gitea.repo shouldBe "my-repo"
}
test("reads builds.maxConcurrent and defaults it to 1") {
val dir = Files.createTempDirectory("gittally-test")
loader.load(dir).builds.maxConcurrent shouldBe 1
dir.resolve(".gittally.yml").toFile().writeText(
"""
builds:
maxConcurrent: 3
""".trimIndent(),
)
loader.load(dir).builds.maxConcurrent shouldBe 3
}
test("repo install config overrides project config for same keys") {
val dir = Files.createTempDirectory("gittally-test")
dir.resolve(".gittally.yml").toFile().writeText(
@@ -216,5 +216,46 @@ class GitServiceTest : FunSpec() {
service.headCommit(fixture.work) shouldMatch Regex("[0-9a-f]{40}")
}
test("worktreeAdd creates a detached worktree at the commit") {
val fixture = Fixture()
val head = service.headCommit(fixture.work)
val worktree = fixture.work.resolve(".git/gittally/worktrees/main-test")
service.worktreeAdd(worktree, head, fixture.work)
Files.readString(worktree.resolve("README.md")) shouldBe "hello"
service.headCommit(worktree) shouldBe head
service.currentBranch(worktree).shouldBeNull()
}
test("checkoutDetached switches a worktree to another commit and discards local modifications") {
val fixture = Fixture()
val firstCommit = service.headCommit(fixture.work)
fixture.commitFile(fixture.work, "second.txt", "second")
val secondCommit = service.headCommit(fixture.work)
val worktree = fixture.work.resolve(".git/gittally/worktrees/main-test")
service.worktreeAdd(worktree, firstCommit, fixture.work)
Files.writeString(worktree.resolve("README.md"), "dirty")
service.checkoutDetached(secondCommit, worktree)
service.headCommit(worktree) shouldBe secondCommit
Files.readString(worktree.resolve("README.md")) shouldBe "hello"
Files.readString(worktree.resolve("second.txt")) shouldBe "second"
}
test("worktreePrune allows re-adding a worktree whose directory was deleted") {
val fixture = Fixture()
val head = service.headCommit(fixture.work)
val worktree = fixture.work.resolve(".git/gittally/worktrees/main-test")
service.worktreeAdd(worktree, head, fixture.work)
worktree.toFile().deleteRecursively()
service.worktreePrune(fixture.work)
service.worktreeAdd(worktree, head, fixture.work)
service.headCommit(worktree) shouldBe head
}
}
}