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
+1 -1
View File
@@ -28,4 +28,4 @@ replay_pid*
# Gradle # Gradle
.gradle/ .gradle/
build/ /build/
+5 -1
View File
@@ -58,9 +58,13 @@ Three places must stay in sync when config keys change: the `GitTallyConfig` dat
`GitService` shells out to the `git` CLI via `GitCommandRunner` (a thin `ProcessBuilder` wrapper; no JGit). Commands that need repo information take it as a constructor dependency so tests can mock it. HTTPS fetches authenticate via a temporary, secret-free `GIT_ASKPASS` script (`GitAskPass`) with credentials from config passed through environment variables. `GitService` shells out to the `git` CLI via `GitCommandRunner` (a thin `ProcessBuilder` wrapper; no JGit). Commands that need repo information take it as a constructor dependency so tests can mock it. HTTPS fetches authenticate via a temporary, secret-free `GIT_ASKPASS` script (`GitAskPass`) with credentials from config passed through environment variables.
### Build Execution
`BuildExecutor` runs builds asynchronously: up to `builds.maxConcurrent` branches concurrently (default 1), but never more than one build per branch at a time. Each branch builds in its own reusable git worktree at `.git/gittally/worktrees/<branchKey>` (`BranchWorkspaces`), checked out detached at the requested commit — the primary checkout is never used for builds. Status transitions are persisted via `BuildResultRepository` (JSON file under `.git/gittally/`), published to Gitea non-fatally, and emitted as `BuildStatusChangedEvent`s. Cancellation addresses a build by artifact key and terminates the whole process tree. Future code (watcher, server, UI) must not assume a single running build.
### Package Structure ### Package Structure
All production code lives under `de.hoennig.gittally`, with sub-packages `commands` (picocli subcommands), `config` (YAML config loading and schema), and `git` (git CLI access). Tests mirror this structure under `src/test/kotlin`. All production code lives under `de.hoennig.gittally`, with sub-packages `commands` (picocli subcommands), `config` (YAML config loading and schema), `git` (git CLI access), `gitea` (Gitea commit-status API client), and `build` (build execution, results, workspaces). Tests mirror this structure under `src/test/kotlin`.
## Testing Conventions ## Testing Conventions
+20 -4
View File
@@ -88,11 +88,11 @@ flowchart LR
### BuildWorker ### BuildWorker
- Pending-Build übernehmen - Pending-Build übernehmen
- Worktree anlegen - Branch-Worktree anlegen oder wiederverwenden
- Commit auschecken - Commit auschecken
- Build starten - Build starten
- Artefakte sammeln - Artefakte sammeln
- Worktree entfernen - Worktrees entfallener Branches entfernen
### GitStatusPublisher ### GitStatusPublisher
@@ -118,10 +118,26 @@ flowchart LR
### Commit-basierte Builds ### Commit-basierte Builds
GitTally baut immer einen konkreten Commit und niemals nur einen Branchnamen. GitTally baut immer einen konkreten Commit und niemals nur einen Branchnamen.
Buildergebnisse werden intern trotzdem pro Branch geführt: derselbe Commit auf zwei Branches ergibt zwei getrennte Builds mit eigenem Status, eigenen Artefakten und eigenem Worktree.
Das ist gewollt, weil Buildläufe den Branchnamen einbeziehen können (Umgebungsvariable `branch`).
In Gitea hängt der Commit-Status dagegen am Commit-SHA: zeigen zwei Branches auf denselben Commit, überschreiben sich ihre Statusmeldungen gegenseitig (der zuletzt gemeldete gewinnt).
Falls das je stört, kann der Branchname später in den Status-Context aufgenommen werden (z. B. `GitTally/main`), sodass ein Commit mehrere unabhängige Statuszeilen bekommt.
### Worktree pro Build ### Worktree pro Branch
Jeder Build erhält einen eigenen temporären Worktree. Jeder Branch erhält einen eigenen, wiederverwendeten Worktree unter `.git/gittally/worktrees/<branchKey>`.
Der Branch-Key ist der dateisystem-sicher bereinigte Branchname plus 12 Zeichen SHA-256 des Originalnamens (z. B. `main-0d6e4079e367`).
Der Hash schützt nur vor Kollisionen durch die Bereinigung (`feature/x` vs. `feature_x`); der Commit ist bewusst nicht Teil des Keys, damit das Verzeichnis über alle Builds des Branches stabil bleibt.
Der zu bauende Commit wird darin detached ausgecheckt.
Der primäre Checkout wird niemals für Builds verwendet.
Die Wiederverwendung erhält inkrementelle Build-Caches; das `cleanCommand` des Branches bestimmt, wie viel davon überlebt.
### Parallele Builds
Mehrere Branches können gleichzeitig bauen (`builds.maxConcurrent`, Default 1).
Pro Branch läuft höchstens ein Build gleichzeitig.
Ein neuer Build desselben Branches wartet, bis der laufende fertig ist.
Ob ein neuer Commit den laufenden Build seines Branches stattdessen abbrechen soll, wird später entschieden und ist möglicherweise konfigurierbar.
## Konfigurationsmodell ## Konfigurationsmodell
+8
View File
@@ -34,6 +34,14 @@ gitea:
repo: my-repo # repository name repo: my-repo # repository name
statusContext: GitTally # label shown on Gitea commit status checks (default: GitTally) 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 runs regardless; each branch builds in its own
# git worktree under .git/gittally/worktrees/, never in the primary checkout.
# Changing this value requires a restart.
maxConcurrent: 1
# Build artifact retention. # Build artifact retention.
artifacts: artifacts:
# number of builds to keep per branch # number of builds to keep per branch
+34
View File
@@ -47,3 +47,37 @@ Consider `builds.timeout` only if trivial; otherwise defer.
- `./gradlew ktlintFormat` then `./gradlew build` is green. - `./gradlew ktlintFormat` then `./gradlew build` is green.
- While a test build sleeps, the repository reports `RUNNING` — proven by a test. - While a test build sleeps, the repository reports `RUNNING` — proven by a test.
## Execution Notes (done 2026-07-07)
Implemented as designed in `de.hoennig.gittally.build`; build green, 18 new tests
(`BuildExecutorTest`, `ProcessBuildRunnerTest`, `ArtifactKeysTest`, plus two new `FileBuildResultRepositoryTest` cases).
Deviations and decisions:
- One-build-at-a-time uses a single-thread worker executor; a second `startBuild` queues and stays `PENDING` until the first finishes.
`PENDING` is persisted synchronously in `startBuild`, so a queued build is immediately visible.
- `BuildResultRepository` gained `updateByArtifactKey(...)` (extends step 01) so transitions always hit the exact entry, even when a newer `PENDING` entry of the same branch was queued meanwhile.
- `cancel()` sets the flag and destroys the process tree directly (TERM, 2s wait, KILL via `ProcessHandle.descendants()`); no separate monitor thread.
The flag is checked before each command and after `waitFor`, so a cancel between clean and build commands still records `CANCELLED`.
- Artifact key naming (`ArtifactKeys`) was needed here because `BuildResult` requires a key; it follows the legacy scheme (sanitized name + 12-char SHA-256 prefix + sanitized ISO timestamp + hash) using the UTC `Instant`, not local time.
Step 05 should reuse it rather than re-implement.
- `ArtifactStore` is an interface in the `build` package with a logging `NoOpArtifactStore` placeholder; step 05 replaces the placeholder and implements the real store in `de.hoennig.gittally.artifacts`.
- The combined live log is `build.log` inside the per-build staging directory (a temp dir exposed via `RunningBuild.stagingDir`/`liveLogFile`); output is flushed per read chunk so the log grows while the build runs.
- Commands run via `bash -c` with the branch name in the environment as `branch`, like legacy `run_build_command`; a failing `cleanCommand` fails the build without running `buildCommand`.
- `BuildResultRepository` is wired as a Spring bean (`BuildConfiguration`) at `.git/gittally/build-results.json` relative to the working directory, matching how `ConfigLoader` resolves the override file; `git rev-parse --git-path` style worktree resolution can come later if needed.
- Gitea `target_url` is not published yet; the artifact page URL scheme only exists from step 07 on.
- `builds.timeout` was deferred (not trivial alongside cancellation semantics); no config keys were added or changed.
## Amendment: Concurrent Builds and Per-Branch Worktrees (2026-07-07)
Refactored on request, superseding parts of the notes above:
- Builds now run concurrently up to the new config key `builds.maxConcurrent` (default 1), enforced by a global semaphore sized on first use (changing it requires a restart).
- At most one build per branch at a time, enforced by one serial worker per branch; a second build of the same branch queues as `PENDING` and runs afterwards ("finish, then next").
Whether a new commit should instead cancel the branch's running build is a later, possibly configurable decision (see step 06).
- Each branch builds in its own reusable git worktree at `.git/gittally/worktrees/<branchKey>` (`BranchWorkspaces`/`GitWorktreeWorkspaces`), checked out detached at the requested commit — the primary checkout is never touched.
Reuse keeps incremental build caches; `cleanCommand` decides how much of them survives.
`GitService` gained `worktreeAdd`, `worktreePrune`, and `checkoutDetached` for this.
- API change: `currentBuild()` became `currentBuilds(): List<RunningBuild>`, and `cancel()` became `cancel(artifactKey)`; a queued build can be cancelled too and is recorded `CANCELLED` when its worker picks it up.
- The Gitea `PENDING` status is now published synchronously in `startBuild`, so queued builds are visible in Gitea while they wait for a slot.
- Branch config is still loaded from the primary repository directory, not from the branch's checked-out `.gittally.yml`; honoring the branch's own committed config would be a separate decision.
+5 -2
View File
@@ -16,8 +16,11 @@ Create package `de.hoennig.gittally.watcher`:
1. `fetchOrigin()` (errors: log, publish watcher health state, retry next cycle — no internal retry-sleep loops like legacy `retry_origin_change_check`). 1. `fetchOrigin()` (errors: log, publish watcher health state, retry next cycle — no internal retry-sleep loops like legacy `retry_origin_change_check`).
2. Determine candidate branches: changed local branches, plus new origin branches within `watcher.newBranchMaxAge` (step 02 operations). 2. Determine candidate branches: changed local branches, plus new origin branches within `watcher.newBranchMaxAge` (step 02 operations).
3. Check auto-build time slots (below). 3. Check auto-build time slots (below).
4. If the executor is idle, dequeue the next branch: checkout, reset to origin, `startBuild` (async). 4. Start builds for due branches via `startBuild(branch, commit)` (async).
5. Run repository retention pruning and artifact pruning. The executor prepares a per-branch worktree itself (step 04 amendment) — the watcher must never check out or reset the primary worktree.
Multiple branches may build concurrently (`builds.maxConcurrent`); the executor already serializes builds of the same branch, so the watcher only has to avoid enqueueing a branch that is already pending or running.
5. Run repository retention pruning and artifact pruning; also remove worktrees under `.git/gittally/worktrees/` of branches no longer on origin (`git worktree remove` or delete + `worktreePrune`).
- Decide here (or defer with a note): when a new commit arrives for a branch whose build is still running, keep the current queue-behind behavior or cancel the running build and start fresh — this may become a per-branch config option.
- Startup sequence (port of legacy recovery): mark stale running builds interrupted, then enqueue restartable branches. - Startup sequence (port of legacy recovery): mark stale running builds interrupted, then enqueue restartable branches.
- Auto-builds: per-branch `autoBuild.enabled` + `times` (UTC HH:MM) from the merged `branches` config. - Auto-builds: per-branch `autoBuild.enabled` + `times` (UTC HH:MM) from the merged `branches` config.
Persist "already triggered for slot/day" state via a small JSON file next to the build results (replaces `auto-builds.tsv`). Persist "already triggered for slot/day" state via a small JSON file next to the build results (replaces `auto-builds.tsv`).
+2 -2
View File
@@ -22,9 +22,9 @@ JSON API (package `de.hoennig.gittally.server`), replacing the legacy `/control/
- `GET /api/builds/latest` — latest build per branch. - `GET /api/builds/latest` — latest build per branch.
- `GET /api/builds/history` — all builds, newest first. - `GET /api/builds/history` — all builds, newest first.
- `GET /api/builds/current` — running build, its live status, and log tail (`?offset=` for incremental log fetch). - `GET /api/builds/current` the list of running builds (there can be several, one per branch, up to `builds.maxConcurrent`), each with live status and log tail (`?offset=` for incremental log fetch, addressed by artifact key).
- `GET /api/status/{commit}` — effective status including Gitea lookup (replaces `/control/status`); must return an explicit error state on Gitea failure, never hang. - `GET /api/status/{commit}` — effective status including Gitea lookup (replaces `/control/status`); must return an explicit error state on Gitea failure, never hang.
- `POST /api/builds/{branch}/restart`, `POST /api/builds/current/cancel`, `DELETE /api/builds/{artifactKey}` — guarded by a simple token like the legacy cancel token; wire into executor/watcher/repository. - `POST /api/builds/{branch}/restart`, `POST /api/builds/{artifactKey}/cancel` (cancel takes the artifact key because multiple builds can run concurrently), `DELETE /api/builds/{artifactKey}` — guarded by a simple token like the legacy cancel token; wire into executor/watcher/repository.
- `GET /api/watcher` — watcher health (last poll, last error). - `GET /api/watcher` — watcher health (last poll, last error).
- Artifact serving: `GET /artifacts/{artifactKey}/**` streaming from the artifact store, with no-cache headers for html/json/log. - Artifact serving: `GET /artifacts/{artifactKey}/**` streaming from the artifact store, with no-cache headers for html/json/log.
+2 -1
View File
@@ -17,7 +17,8 @@ Views (ported from legacy, see analysis for columns and behavior):
- `/` (Latest): latest build per branch — status badge, branch and commit with Gitea links and copy buttons, times, duration, artifact link, restart/delete actions. - `/` (Latest): latest build per branch — status badge, branch and commit with Gitea links and copy buttons, times, duration, artifact link, restart/delete actions.
- `/history`: all builds. - `/history`: all builds.
- `/current`: running build with live log view (incremental fetch via `/api/builds/current?offset=`), cancel button, and a clear "no build running" state. - `/current`: running builds with live log view (incremental fetch via the step 07 current-builds API), a cancel button per build, and a clear "no build running" state.
Remember there can be several running builds (one per branch, up to `builds.maxConcurrent`); the view must list all of them, not assume a single one.
- `/builds/{artifactKey}`: artifact index — build command, logs, links into archived report directories (rendered from the artifact store, not pre-generated HTML). - `/builds/{artifactKey}`: artifact index — build command, logs, links into archived report directories (rendered from the artifact store, not pre-generated HTML).
- Shared layout: view toggle nav, footer with version and optional impressum link, `prefers-color-scheme` support (port the legacy CSS look loosely, keep it simple). - Shared layout: view toggle nav, footer with version and optional impressum link, `prefers-color-scheme` support (port the legacy CSS look loosely, keep it simple).
+4 -1
View File
@@ -30,6 +30,9 @@ These are proposals baked into the steps.
Revisit them in an ADR if a step uncovers problems. Revisit them in an ADR if a step uncovers problems.
- Build results are persisted as a JSON file under `.git/gittally/`, behind a `BuildResultRepository` interface (no database, but replaceable). - Build results are persisted as a JSON file under `.git/gittally/`, behind a `BuildResultRepository` interface (no database, but replaceable).
- Builds run concurrently up to `builds.maxConcurrent` (default 1), but never more than one build per branch at a time.
Each branch builds in its own reusable git worktree under `.git/gittally/worktrees/`, checked out detached at the requested commit — never in the primary checkout.
A later step must decide (possibly per config) whether a new commit on a branch cancels that branch's running build or waits for it; for now new builds queue behind the running one.
- Artifacts stay on the filesystem, served by the Spring server. - Artifacts stay on the filesystem, served by the Spring server.
- The web UI is server-rendered HTML plus small JavaScript polling JSON endpoints (no SPA framework). - The web UI is server-rendered HTML plus small JavaScript polling JSON endpoints (no SPA framework).
- The watcher is a Spring-managed scheduled component, decoupled from the build executor via the result repository and events. - The watcher is a Spring-managed scheduled component, decoupled from the build executor via the result repository and events.
@@ -45,7 +48,7 @@ Foundation:
Core engine: Core engine:
- [ ] `04-build-executor.md` — async build execution with logs, cancellation, status transitions - [x] `04-build-executor.md` — async build execution with logs, cancellation, status transitions
- [ ] `05-artifact-store.md` — artifact persistence, naming, retention - [ ] `05-artifact-store.md` — artifact persistence, naming, retention
- [ ] `06-watcher.md` — branch watching, scheduling, auto-builds - [ ] `06-watcher.md` — branch watching, scheduling, auto-builds
@@ -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 repo: ${detected.repo} # repository name
statusContext: GitTally # label shown on Gitea commit status checks (default: GitTally) 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. # Build artifact retention.
artifacts: artifacts:
# number of builds to keep per branch # number of builds to keep per branch
@@ -4,6 +4,7 @@ data class GitTallyConfig(
val server: ServerConfig = ServerConfig(), val server: ServerConfig = ServerConfig(),
val git: GitConfig = GitConfig(), val git: GitConfig = GitConfig(),
val gitea: GiteaConfig = GiteaConfig(), val gitea: GiteaConfig = GiteaConfig(),
val builds: BuildsConfig = BuildsConfig(),
val artifacts: ArtifactsConfig = ArtifactsConfig(), val artifacts: ArtifactsConfig = ArtifactsConfig(),
val watcher: WatcherConfig = WatcherConfig(), val watcher: WatcherConfig = WatcherConfig(),
val branches: Map<String, BranchConfig> = mapOf("default" to BranchConfig()), val branches: Map<String, BranchConfig> = mapOf("default" to BranchConfig()),
@@ -25,6 +26,11 @@ data class GiteaConfig(
val statusContext: String = "GitTally", 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( data class ArtifactsConfig(
val retentionPerBranch: Int = 3, val retentionPerBranch: Int = 3,
) )
@@ -157,6 +157,28 @@ class GitService(
.stdout .stdout
.trim() .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( private fun refExists(
ref: String, ref: String,
workingDir: Path, 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" 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") { test("repo install config overrides project config for same keys") {
val dir = Files.createTempDirectory("gittally-test") val dir = Files.createTempDirectory("gittally-test")
dir.resolve(".gittally.yml").toFile().writeText( dir.resolve(".gittally.yml").toFile().writeText(
@@ -216,5 +216,46 @@ class GitServiceTest : FunSpec() {
service.headCommit(fixture.work) shouldMatch Regex("[0-9a-f]{40}") 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
}
} }
} }