6.5 KiB
Step 04: Build Executor
Prerequisites: steps 01, 02, 03.
Read README.md and 00-legacy-analysis.md first.
Goal
Asynchronous build execution with log capture, cancellation, and immediately visible status transitions. This step fixes the legacy defect that nothing could observe status changes while a build ran.
Design
Create package de.hoennig.gittally.build (extends step 01):
BuildExecutorservice; one build at a time (aReentrantLockor single-thread executor replaces the legacy flock file).startBuild(branch, commit)runs asynchronously and returns immediately; exposecurrentBuild(): RunningBuild?.- Execution sequence per build:
- Record
PENDING, thenRUNNINGviaBuildResultRepository; publish each transition to Gitea (non-fatal on failure). - Run the branch's
cleanCommand, thenbuildCommand(from the mergedbranchesconfig) viaProcessBuilderwith the branch name in the environment asbranch. - Stream stdout/stderr to the configured log files in a working/staging directory, plus a combined live log file.
- On exit: record
SUCCESS/FAILED, publish status, hand the staging directory to the artifact store (step 05 interface; use a stub interface now if 05 is not done).
- Record
- Cancellation:
cancel()flag checked by a monitor; destroy the process tree (ProcessHandle.descendants(), TERM-wait-KILL like legacyterminate_process_tree); recordCANCELLED. - Status transitions must be readable at any time via the repository — no in-memory-only state.
Emit a Spring ApplicationEvent on every status transition so the UI (step 08) can later push updates without polling internals.
Out of Scope
- Docker execution (step 11); native
ProcessBuilderonly, but keep aBuildRunnerinterface so Docker can plug in. - Scheduling and branch selection (step 06).
- Artifact index HTML (step 05/08).
Config
Uses existing branches.<name>.buildCommand/cleanCommand/stdoutLog/stderrLog.
Consider builds.timeout only if trivial; otherwise defer.
Tests
- Fake commands (
sh -c 'echo ok', failing command, sleeping command) in temp dirs. - Status sequence assertions: pending → running → success/failed/cancelled, each persisted before/after execution.
- Cancellation kills a sleeping process tree and records
CANCELLED. - Log files contain captured output; live log grows during the build (poll in test).
- Gitea publishing mocked with MockK; a Gitea failure must not fail the build.
Acceptance Criteria
./gradlew ktlintFormatthen./gradlew buildis green.- While a test build sleeps, the repository reports
RUNNING— proven by a test.
Execution Notes (done 2026-07-07)
Implemented as designed in de.hoennig.gittally.build; build green, 18 new tests
(BuildExecutorTest, ProcessBuildRunnerTest, ArtifactKeysTest, plus two new FileBuildResultRepositoryTest cases).
Deviations and decisions:
- One-build-at-a-time uses a single-thread worker executor; a second
startBuildqueues and staysPENDINGuntil the first finishes.PENDINGis persisted synchronously instartBuild, so a queued build is immediately visible. BuildResultRepositorygainedupdateByArtifactKey(...)(extends step 01) so transitions always hit the exact entry, even when a newerPENDINGentry of the same branch was queued meanwhile.cancel()sets the flag and destroys the process tree directly (TERM, 2s wait, KILL viaProcessHandle.descendants()); no separate monitor thread. The flag is checked before each command and afterwaitFor, so a cancel between clean and build commands still recordsCANCELLED.- Artifact key naming (
ArtifactKeys) was needed here becauseBuildResultrequires a key; it follows the legacy scheme (sanitized name + 12-char SHA-256 prefix + sanitized ISO timestamp + hash) using the UTCInstant, not local time. Step 05 should reuse it rather than re-implement. ArtifactStoreis an interface in thebuildpackage with a loggingNoOpArtifactStoreplaceholder; step 05 replaces the placeholder and implements the real store inde.hoennig.gittally.artifacts.- The combined live log is
build.loginside the per-build staging directory (a temp dir exposed viaRunningBuild.stagingDir/liveLogFile); output is flushed per read chunk so the log grows while the build runs. - Commands run via
bash -cwith the branch name in the environment asbranch, like legacyrun_build_command; a failingcleanCommandfails the build without runningbuildCommand. BuildResultRepositoryis wired as a Spring bean (BuildConfiguration) at.git/gittally/build-results.jsonrelative to the working directory, matching howConfigLoaderresolves the override file;git rev-parse --git-pathstyle worktree resolution can come later if needed.- Gitea
target_urlis not published yet; the artifact page URL scheme only exists from step 07 on. builds.timeoutwas 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
PENDINGand 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;cleanCommanddecides how much of them survives.GitServicegainedworktreeAdd,worktreePrune, andcheckoutDetachedfor this. - API change:
currentBuild()becamecurrentBuilds(): List<RunningBuild>, andcancel()becamecancel(artifactKey); a queued build can be cancelled too and is recordedCANCELLEDwhen its worker picks it up. - The Gitea
PENDINGstatus is now published synchronously instartBuild, 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.