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:
co-authored by
Claude Fable 5
parent
ae3ae7aa04
commit
b379bc0a6b
+1
-1
@@ -28,4 +28,4 @@ replay_pid*
|
|||||||
|
|
||||||
# Gradle
|
# Gradle
|
||||||
.gradle/
|
.gradle/
|
||||||
build/
|
/build/
|
||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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`).
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user