implemented 04-build-executor.md incl. concurrency amendment: async builds in per-branch worktrees, builds.maxConcurrent, cancellation, live logs; fix .gitignore build/ rule that silently excluded the de.hoennig.gittally.build package (also recovers the step-01 domain files)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Michael Hoennig
2026-07-07 08:43:28 +02:00
co-authored by Claude Fable 5
parent ae3ae7aa04
commit b379bc0a6b
31 changed files with 1790 additions and 12 deletions
@@ -0,0 +1,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
}
}
}