From b379bc0a6b5706b5109435dd906427124f13543c Mon Sep 17 00:00:00 2001 From: Michael Hoennig Date: Tue, 7 Jul 2026 08:43:28 +0200 Subject: [PATCH] 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 --- .gitignore | 2 +- CLAUDE.md | 6 +- docs/GitTally-Konzept.md | 24 +- docs/configuration.md | 8 + docs/plan/04-build-executor.md | 34 ++ docs/plan/06-watcher.md | 7 +- docs/plan/07-server-mode.md | 4 +- docs/plan/08-web-ui.md | 3 +- docs/plan/README.md | 5 +- .../de/hoennig/gittally/build/ArtifactKeys.kt | 27 ++ .../hoennig/gittally/build/ArtifactStore.kt | 29 ++ .../gittally/build/BranchWorkspaces.kt | 55 +++ .../gittally/build/BuildConfiguration.kt | 16 + .../hoennig/gittally/build/BuildExecutor.kt | 385 ++++++++++++++++++ .../de/hoennig/gittally/build/BuildResult.kt | 13 + .../gittally/build/BuildResultRepository.kt | 43 ++ .../de/hoennig/gittally/build/BuildRunner.kt | 31 ++ .../de/hoennig/gittally/build/BuildStatus.kt | 17 + .../build/FileBuildResultRepository.kt | 185 +++++++++ .../de/hoennig/gittally/build/RunningBuild.kt | 21 + .../hoennig/gittally/commands/InitCommand.kt | 5 + .../hoennig/gittally/config/GitTallyConfig.kt | 6 + .../de/hoennig/gittally/git/GitService.kt | 22 + .../gittally/build/ArtifactKeysTest.kt | 38 ++ .../gittally/build/BuildExecutorTest.kt | 326 +++++++++++++++ .../hoennig/gittally/build/BuildStatusTest.kt | 18 + .../build/FileBuildResultRepositoryTest.kt | 244 +++++++++++ .../build/GitWorktreeWorkspacesTest.kt | 126 ++++++ .../gittally/build/ProcessBuildRunnerTest.kt | 48 +++ .../gittally/config/ConfigLoaderTest.kt | 13 + .../de/hoennig/gittally/git/GitServiceTest.kt | 41 ++ 31 files changed, 1790 insertions(+), 12 deletions(-) create mode 100644 src/main/kotlin/de/hoennig/gittally/build/ArtifactKeys.kt create mode 100644 src/main/kotlin/de/hoennig/gittally/build/ArtifactStore.kt create mode 100644 src/main/kotlin/de/hoennig/gittally/build/BranchWorkspaces.kt create mode 100644 src/main/kotlin/de/hoennig/gittally/build/BuildConfiguration.kt create mode 100644 src/main/kotlin/de/hoennig/gittally/build/BuildExecutor.kt create mode 100644 src/main/kotlin/de/hoennig/gittally/build/BuildResult.kt create mode 100644 src/main/kotlin/de/hoennig/gittally/build/BuildResultRepository.kt create mode 100644 src/main/kotlin/de/hoennig/gittally/build/BuildRunner.kt create mode 100644 src/main/kotlin/de/hoennig/gittally/build/BuildStatus.kt create mode 100644 src/main/kotlin/de/hoennig/gittally/build/FileBuildResultRepository.kt create mode 100644 src/main/kotlin/de/hoennig/gittally/build/RunningBuild.kt create mode 100644 src/test/kotlin/de/hoennig/gittally/build/ArtifactKeysTest.kt create mode 100644 src/test/kotlin/de/hoennig/gittally/build/BuildExecutorTest.kt create mode 100644 src/test/kotlin/de/hoennig/gittally/build/BuildStatusTest.kt create mode 100644 src/test/kotlin/de/hoennig/gittally/build/FileBuildResultRepositoryTest.kt create mode 100644 src/test/kotlin/de/hoennig/gittally/build/GitWorktreeWorkspacesTest.kt create mode 100644 src/test/kotlin/de/hoennig/gittally/build/ProcessBuildRunnerTest.kt diff --git a/.gitignore b/.gitignore index 4662e90..d16e596 100644 --- a/.gitignore +++ b/.gitignore @@ -28,4 +28,4 @@ replay_pid* # Gradle .gradle/ -build/ \ No newline at end of file +/build/ \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md index ac28ca0..ac34e20 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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. +### 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/` (`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 -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 diff --git a/docs/GitTally-Konzept.md b/docs/GitTally-Konzept.md index bf459c4..9aac13e 100644 --- a/docs/GitTally-Konzept.md +++ b/docs/GitTally-Konzept.md @@ -88,11 +88,11 @@ flowchart LR ### BuildWorker - Pending-Build übernehmen -- Worktree anlegen +- Branch-Worktree anlegen oder wiederverwenden - Commit auschecken - Build starten - Artefakte sammeln -- Worktree entfernen +- Worktrees entfallener Branches entfernen ### GitStatusPublisher @@ -118,10 +118,26 @@ flowchart LR ### Commit-basierte Builds 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/`. +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 diff --git a/docs/configuration.md b/docs/configuration.md index bd6725c..b5bbf34 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -34,6 +34,14 @@ gitea: repo: my-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 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. artifacts: # number of builds to keep per branch diff --git a/docs/plan/04-build-executor.md b/docs/plan/04-build-executor.md index 9fbe449..3640409 100644 --- a/docs/plan/04-build-executor.md +++ b/docs/plan/04-build-executor.md @@ -47,3 +47,37 @@ Consider `builds.timeout` only if trivial; otherwise defer. - `./gradlew ktlintFormat` then `./gradlew build` is green. - 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/` (`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`, 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. diff --git a/docs/plan/06-watcher.md b/docs/plan/06-watcher.md index cd7b31b..6e64063 100644 --- a/docs/plan/06-watcher.md +++ b/docs/plan/06-watcher.md @@ -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`). 2. Determine candidate branches: changed local branches, plus new origin branches within `watcher.newBranchMaxAge` (step 02 operations). 3. Check auto-build time slots (below). - 4. If the executor is idle, dequeue the next branch: checkout, reset to origin, `startBuild` (async). - 5. Run repository retention pruning and artifact pruning. + 4. Start builds for due branches via `startBuild(branch, commit)` (async). + 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. - 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`). diff --git a/docs/plan/07-server-mode.md b/docs/plan/07-server-mode.md index ee746f4..bf7e8fe 100644 --- a/docs/plan/07-server-mode.md +++ b/docs/plan/07-server-mode.md @@ -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/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. -- `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). - Artifact serving: `GET /artifacts/{artifactKey}/**` streaming from the artifact store, with no-cache headers for html/json/log. diff --git a/docs/plan/08-web-ui.md b/docs/plan/08-web-ui.md index 74d0076..6e2c85c 100644 --- a/docs/plan/08-web-ui.md +++ b/docs/plan/08-web-ui.md @@ -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. - `/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). - 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). diff --git a/docs/plan/README.md b/docs/plan/README.md index 122791c..c0c3f37 100644 --- a/docs/plan/README.md +++ b/docs/plan/README.md @@ -30,6 +30,9 @@ These are proposals baked into the steps. 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). +- 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. - 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. @@ -45,7 +48,7 @@ Foundation: 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 - [ ] `06-watcher.md` — branch watching, scheduling, auto-builds diff --git a/src/main/kotlin/de/hoennig/gittally/build/ArtifactKeys.kt b/src/main/kotlin/de/hoennig/gittally/build/ArtifactKeys.kt new file mode 100644 index 0000000..701fb1f --- /dev/null +++ b/src/main/kotlin/de/hoennig/gittally/build/ArtifactKeys.kt @@ -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) +} diff --git a/src/main/kotlin/de/hoennig/gittally/build/ArtifactStore.kt b/src/main/kotlin/de/hoennig/gittally/build/ArtifactStore.kt new file mode 100644 index 0000000..0ea1eaf --- /dev/null +++ b/src/main/kotlin/de/hoennig/gittally/build/ArtifactStore.kt @@ -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) + } +} diff --git a/src/main/kotlin/de/hoennig/gittally/build/BranchWorkspaces.kt b/src/main/kotlin/de/hoennig/gittally/build/BranchWorkspaces.kt new file mode 100644 index 0000000..5c40d36 --- /dev/null +++ b/src/main/kotlin/de/hoennig/gittally/build/BranchWorkspaces.kt @@ -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/`, + * 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" + } +} diff --git a/src/main/kotlin/de/hoennig/gittally/build/BuildConfiguration.kt b/src/main/kotlin/de/hoennig/gittally/build/BuildConfiguration.kt new file mode 100644 index 0000000..165d13b --- /dev/null +++ b/src/main/kotlin/de/hoennig/gittally/build/BuildConfiguration.kt @@ -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")) +} diff --git a/src/main/kotlin/de/hoennig/gittally/build/BuildExecutor.kt b/src/main/kotlin/de/hoennig/gittally/build/BuildExecutor.kt new file mode 100644 index 0000000..3771114 --- /dev/null +++ b/src/main/kotlin/de/hoennig/gittally/build/BuildExecutor.kt @@ -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() + + /** All accepted, not yet finished builds by artifact key — queued and running. */ + private val builds = ConcurrentHashMap() + + /** 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 = 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 + } +} diff --git a/src/main/kotlin/de/hoennig/gittally/build/BuildResult.kt b/src/main/kotlin/de/hoennig/gittally/build/BuildResult.kt new file mode 100644 index 0000000..5ae8612 --- /dev/null +++ b/src/main/kotlin/de/hoennig/gittally/build/BuildResult.kt @@ -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, +) diff --git a/src/main/kotlin/de/hoennig/gittally/build/BuildResultRepository.kt b/src/main/kotlin/de/hoennig/gittally/build/BuildResultRepository.kt new file mode 100644 index 0000000..814deda --- /dev/null +++ b/src/main/kotlin/de/hoennig/gittally/build/BuildResultRepository.kt @@ -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 + + /** All entries, newest first. */ + fun history(): List + + /** 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 + + /** + * Keeps the newest [retentionPerBranch] entries per branch and drops entries of branches + * not contained in [originBranches]. Returns the removed entries. + */ + fun prune( + originBranches: Collection, + retentionPerBranch: Int, + ): List +} diff --git a/src/main/kotlin/de/hoennig/gittally/build/BuildRunner.kt b/src/main/kotlin/de/hoennig/gittally/build/BuildRunner.kt new file mode 100644 index 0000000..d4253c4 --- /dev/null +++ b/src/main/kotlin/de/hoennig/gittally/build/BuildRunner.kt @@ -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, + ): Process +} + +@Component +class ProcessBuildRunner : BuildRunner { + override fun start( + command: String, + workingDir: Path, + environment: Map, + ): Process { + val processBuilder = ProcessBuilder("bash", "-c", command) + processBuilder.directory(workingDir.toFile()) + processBuilder.environment().putAll(environment) + return processBuilder.start() + } +} diff --git a/src/main/kotlin/de/hoennig/gittally/build/BuildStatus.kt b/src/main/kotlin/de/hoennig/gittally/build/BuildStatus.kt new file mode 100644 index 0000000..70997cc --- /dev/null +++ b/src/main/kotlin/de/hoennig/gittally/build/BuildStatus.kt @@ -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 +} diff --git a/src/main/kotlin/de/hoennig/gittally/build/FileBuildResultRepository.kt b/src/main/kotlin/de/hoennig/gittally/build/FileBuildResultRepository.kt new file mode 100644 index 0000000..55fa485 --- /dev/null +++ b/src/main/kotlin/de/hoennig/gittally/build/FileBuildResultRepository.kt @@ -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 = + load() + .groupBy { it.branch } + .values + .map { entries -> entries.reduce(::laterOf) } + .sortedByDescending { it.startedAt } + + override fun history(): List = 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 { + synchronized(lock) { + val results = load() + val changed = mutableListOf() + 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, + retentionPerBranch: Int, + ): List { + 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, + 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 { + if (!Files.exists(file)) { + return emptyList() + } + return try { + json.readValue>(file.toFile()) + } catch (e: Exception) { + log.warn("ignoring unreadable build results file {}: {}", file, e.message) + emptyList() + } + } + + private fun save(results: List) { + 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) + } + } +} diff --git a/src/main/kotlin/de/hoennig/gittally/build/RunningBuild.kt b/src/main/kotlin/de/hoennig/gittally/build/RunningBuild.kt new file mode 100644 index 0000000..9f539e8 --- /dev/null +++ b/src/main/kotlin/de/hoennig/gittally/build/RunningBuild.kt @@ -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, +) diff --git a/src/main/kotlin/de/hoennig/gittally/commands/InitCommand.kt b/src/main/kotlin/de/hoennig/gittally/commands/InitCommand.kt index e6f0456..56d805f 100644 --- a/src/main/kotlin/de/hoennig/gittally/commands/InitCommand.kt +++ b/src/main/kotlin/de/hoennig/gittally/commands/InitCommand.kt @@ -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 diff --git a/src/main/kotlin/de/hoennig/gittally/config/GitTallyConfig.kt b/src/main/kotlin/de/hoennig/gittally/config/GitTallyConfig.kt index 0c2bceb..7ed1a1e 100644 --- a/src/main/kotlin/de/hoennig/gittally/config/GitTallyConfig.kt +++ b/src/main/kotlin/de/hoennig/gittally/config/GitTallyConfig.kt @@ -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 = 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, ) diff --git a/src/main/kotlin/de/hoennig/gittally/git/GitService.kt b/src/main/kotlin/de/hoennig/gittally/git/GitService.kt index 411e74c..75cde75 100644 --- a/src/main/kotlin/de/hoennig/gittally/git/GitService.kt +++ b/src/main/kotlin/de/hoennig/gittally/git/GitService.kt @@ -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, diff --git a/src/test/kotlin/de/hoennig/gittally/build/ArtifactKeysTest.kt b/src/test/kotlin/de/hoennig/gittally/build/ArtifactKeysTest.kt new file mode 100644 index 0000000..82b4c1f --- /dev/null +++ b/src/test/kotlin/de/hoennig/gittally/build/ArtifactKeysTest.kt @@ -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" + } + } +} diff --git a/src/test/kotlin/de/hoennig/gittally/build/BuildExecutorTest.kt b/src/test/kotlin/de/hoennig/gittally/build/BuildExecutorTest.kt new file mode 100644 index 0000000..691037a --- /dev/null +++ b/src/test/kotlin/de/hoennig/gittally/build/BuildExecutorTest.kt @@ -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(relaxed = true) + val artifactStore = mockk(relaxed = true) + val events = CopyOnWriteArrayList() + val workspaceCalls = CopyOnWriteArrayList>() + 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() + 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) + } + } +} diff --git a/src/test/kotlin/de/hoennig/gittally/build/BuildStatusTest.kt b/src/test/kotlin/de/hoennig/gittally/build/BuildStatusTest.kt new file mode 100644 index 0000000..fd8aa0c --- /dev/null +++ b/src/test/kotlin/de/hoennig/gittally/build/BuildStatusTest.kt @@ -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) + } + } +} diff --git a/src/test/kotlin/de/hoennig/gittally/build/FileBuildResultRepositoryTest.kt b/src/test/kotlin/de/hoennig/gittally/build/FileBuildResultRepositoryTest.kt new file mode 100644 index 0000000..3c96bd6 --- /dev/null +++ b/src/test/kotlin/de/hoennig/gittally/build/FileBuildResultRepositoryTest.kt @@ -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") + } + } + } +} diff --git a/src/test/kotlin/de/hoennig/gittally/build/GitWorktreeWorkspacesTest.kt b/src/test/kotlin/de/hoennig/gittally/build/GitWorktreeWorkspacesTest.kt new file mode 100644 index 0000000..822a7ea --- /dev/null +++ b/src/test/kotlin/de/hoennig/gittally/build/GitWorktreeWorkspacesTest.kt @@ -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" + } + } +} diff --git a/src/test/kotlin/de/hoennig/gittally/build/ProcessBuildRunnerTest.kt b/src/test/kotlin/de/hoennig/gittally/build/ProcessBuildRunnerTest.kt new file mode 100644 index 0000000..12a802b --- /dev/null +++ b/src/test/kotlin/de/hoennig/gittally/build/ProcessBuildRunnerTest.kt @@ -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 + } + } +} diff --git a/src/test/kotlin/de/hoennig/gittally/config/ConfigLoaderTest.kt b/src/test/kotlin/de/hoennig/gittally/config/ConfigLoaderTest.kt index 3b92510..070bf87 100644 --- a/src/test/kotlin/de/hoennig/gittally/config/ConfigLoaderTest.kt +++ b/src/test/kotlin/de/hoennig/gittally/config/ConfigLoaderTest.kt @@ -34,6 +34,19 @@ class ConfigLoaderTest : FunSpec() { config.gitea.repo shouldBe "my-repo" } + test("reads builds.maxConcurrent and defaults it to 1") { + val dir = Files.createTempDirectory("gittally-test") + loader.load(dir).builds.maxConcurrent shouldBe 1 + + dir.resolve(".gittally.yml").toFile().writeText( + """ + builds: + maxConcurrent: 3 + """.trimIndent(), + ) + loader.load(dir).builds.maxConcurrent shouldBe 3 + } + test("repo install config overrides project config for same keys") { val dir = Files.createTempDirectory("gittally-test") dir.resolve(".gittally.yml").toFile().writeText( diff --git a/src/test/kotlin/de/hoennig/gittally/git/GitServiceTest.kt b/src/test/kotlin/de/hoennig/gittally/git/GitServiceTest.kt index ed2e42c..40223fc 100644 --- a/src/test/kotlin/de/hoennig/gittally/git/GitServiceTest.kt +++ b/src/test/kotlin/de/hoennig/gittally/git/GitServiceTest.kt @@ -216,5 +216,46 @@ class GitServiceTest : FunSpec() { service.headCommit(fixture.work) shouldMatch Regex("[0-9a-f]{40}") } + + test("worktreeAdd creates a detached worktree at the commit") { + val fixture = Fixture() + val head = service.headCommit(fixture.work) + val worktree = fixture.work.resolve(".git/gittally/worktrees/main-test") + + service.worktreeAdd(worktree, head, fixture.work) + + Files.readString(worktree.resolve("README.md")) shouldBe "hello" + service.headCommit(worktree) shouldBe head + service.currentBranch(worktree).shouldBeNull() + } + + test("checkoutDetached switches a worktree to another commit and discards local modifications") { + val fixture = Fixture() + val firstCommit = service.headCommit(fixture.work) + fixture.commitFile(fixture.work, "second.txt", "second") + val secondCommit = service.headCommit(fixture.work) + val worktree = fixture.work.resolve(".git/gittally/worktrees/main-test") + service.worktreeAdd(worktree, firstCommit, fixture.work) + Files.writeString(worktree.resolve("README.md"), "dirty") + + service.checkoutDetached(secondCommit, worktree) + + service.headCommit(worktree) shouldBe secondCommit + Files.readString(worktree.resolve("README.md")) shouldBe "hello" + Files.readString(worktree.resolve("second.txt")) shouldBe "second" + } + + test("worktreePrune allows re-adding a worktree whose directory was deleted") { + val fixture = Fixture() + val head = service.headCommit(fixture.work) + val worktree = fixture.work.resolve(".git/gittally/worktrees/main-test") + service.worktreeAdd(worktree, head, fixture.work) + worktree.toFile().deleteRecursively() + + service.worktreePrune(fixture.work) + + service.worktreeAdd(worktree, head, fixture.work) + service.headCommit(worktree) shouldBe head + } } }