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,