implemented 10-cli-commands.md: added CLI commands for branch name resolution, one-shot builds, failed build retries, and build status reports; includes tests for functionality and edge cases

This commit is contained in:
Michael Hoennig
2026-07-07 13:19:29 +02:00
parent a6a2c9de0b
commit 73221a8b8b
19 changed files with 943 additions and 2 deletions
@@ -0,0 +1,30 @@
package de.hoennig.gittally.commands
import io.kotest.core.spec.style.FunSpec
import io.kotest.matchers.shouldBe
class BranchNameResolutionTest : FunSpec() {
private val candidates = listOf("main", "main-backup", "feature/login", "feature/logout", "hotfix/1.2.3")
init {
test("a unique fragment resolves to the full branch name") {
BranchNameResolution.resolve("login", candidates) shouldBe
BranchNameResolution.Resolved("feature/login")
}
test("an exact branch name wins even when other branches contain it") {
BranchNameResolution.resolve("main", candidates) shouldBe
BranchNameResolution.Resolved("main")
}
test("an ambiguous fragment reports all matching candidates") {
BranchNameResolution.resolve("feature/log", candidates) shouldBe
BranchNameResolution.Ambiguous(listOf("feature/login", "feature/logout"))
}
test("a fragment without any match reports no match") {
BranchNameResolution.resolve("release", candidates) shouldBe
BranchNameResolution.NoMatch
}
}
}
@@ -0,0 +1,138 @@
package de.hoennig.gittally.commands
import de.hoennig.gittally.build.BuildStatus
import de.hoennig.gittally.git.GitService
import io.kotest.core.spec.style.FunSpec
import io.kotest.matchers.shouldBe
import io.kotest.matchers.string.shouldContain
import io.mockk.Called
import io.mockk.clearMocks
import io.mockk.every
import io.mockk.justRun
import io.mockk.mockk
import io.mockk.verify
import java.nio.file.Path
import java.nio.file.Paths
class BuildCommandTest : FunSpec() {
private val gitService = mockk<GitService>()
private val consoleBuildRunner = mockk<ConsoleBuildRunner>()
private val dir: Path = Paths.get(".")
private fun command(fragment: String? = null) =
BuildCommand(gitService, consoleBuildRunner).apply {
branchFragment = fragment
workingDir = dir
}
init {
beforeEach {
clearMocks(gitService, consoleBuildRunner)
justRun { gitService.fetchOrigin(dir) }
}
test("builds the current branch at its local head when no branch is given") {
every { gitService.currentBranch(dir) } returns "main"
every { gitService.localHeadCommit("main", dir) } returns "local-head"
every { gitService.hasNewCommits("main", dir) } returns false
every { consoleBuildRunner.buildAndStream("main", "local-head", dir) } returns BuildStatus.SUCCESS
var exitCode = -1
captureConsole { exitCode = command().call() }
exitCode shouldBe 0
verify { consoleBuildRunner.buildAndStream("main", "local-head", dir) }
}
test("builds origin's head when the branch has new commits on origin") {
every { gitService.currentBranch(dir) } returns "main"
every { gitService.localHeadCommit("main", dir) } returns "local-head"
every { gitService.hasNewCommits("main", dir) } returns true
every { gitService.originHeadCommit("main", dir) } returns "origin-head"
every { consoleBuildRunner.buildAndStream("main", "origin-head", dir) } returns BuildStatus.SUCCESS
var exitCode = -1
captureConsole { exitCode = command().call() }
exitCode shouldBe 0
verify { consoleBuildRunner.buildAndStream("main", "origin-head", dir) }
}
test("a failing build exits with code 1") {
every { gitService.currentBranch(dir) } returns "main"
every { gitService.localHeadCommit("main", dir) } returns "local-head"
every { gitService.hasNewCommits("main", dir) } returns false
every { consoleBuildRunner.buildAndStream("main", "local-head", dir) } returns BuildStatus.FAILED
var exitCode = -1
captureConsole { exitCode = command().call() }
exitCode shouldBe 1
}
test("resolves a unique branch-name fragment against local and origin branches") {
every { gitService.localBranches(dir) } returns listOf("main")
every { gitService.originBranches(dir) } returns listOf("main", "feature/x")
every { gitService.localHeadCommit("feature/x", dir) } returns null
every { gitService.originHeadCommit("feature/x", dir) } returns "origin-head"
every { consoleBuildRunner.buildAndStream("feature/x", "origin-head", dir) } returns BuildStatus.SUCCESS
var exitCode = -1
captureConsole { exitCode = command(fragment = "x").call() }
exitCode shouldBe 0
verify { consoleBuildRunner.buildAndStream("feature/x", "origin-head", dir) }
}
test("an ambiguous fragment lists the candidates and exits with code 2") {
every { gitService.localBranches(dir) } returns listOf("feature/login")
every { gitService.originBranches(dir) } returns listOf("feature/logout")
var exitCode = -1
val console = captureConsole { exitCode = command(fragment = "feature").call() }
exitCode shouldBe 2
console.stderr shouldContain "multiple branches match 'feature'"
console.stderr shouldContain "feature/login"
console.stderr shouldContain "feature/logout"
verify { consoleBuildRunner wasNot Called }
}
test("a fragment without any match exits with code 2") {
every { gitService.localBranches(dir) } returns listOf("main")
every { gitService.originBranches(dir) } returns listOf("main")
var exitCode = -1
val console = captureConsole { exitCode = command(fragment = "release").call() }
exitCode shouldBe 2
console.stderr shouldContain "no local or origin branch matches 'release'"
verify { consoleBuildRunner wasNot Called }
}
test("a detached HEAD without a branch argument exits with code 2") {
every { gitService.currentBranch(dir) } returns null
var exitCode = -1
val console = captureConsole { exitCode = command().call() }
exitCode shouldBe 2
console.stderr shouldContain "HEAD is detached"
verify { consoleBuildRunner wasNot Called }
}
test("a failed fetch only warns and the build continues from the last known origin state") {
every { gitService.fetchOrigin(dir) } throws RuntimeException("origin unreachable")
every { gitService.currentBranch(dir) } returns "main"
every { gitService.localHeadCommit("main", dir) } returns "local-head"
every { gitService.hasNewCommits("main", dir) } returns false
every { consoleBuildRunner.buildAndStream("main", "local-head", dir) } returns BuildStatus.SUCCESS
var exitCode = -1
val console = captureConsole { exitCode = command().call() }
exitCode shouldBe 0
console.stderr shouldContain "warning: fetching origin failed"
}
}
}
@@ -0,0 +1,26 @@
package de.hoennig.gittally.commands
import java.io.ByteArrayOutputStream
import java.io.PrintStream
data class CapturedConsole(
val stdout: String,
val stderr: String,
)
/** Captures `System.out` and `System.err` while [block] runs. */
fun captureConsole(block: () -> Unit): CapturedConsole {
val out = ByteArrayOutputStream()
val err = ByteArrayOutputStream()
val previousOut = System.out
val previousErr = System.err
System.setOut(PrintStream(out, true))
System.setErr(PrintStream(err, true))
try {
block()
} finally {
System.setOut(previousOut)
System.setErr(previousErr)
}
return CapturedConsole(out.toString(), err.toString())
}
@@ -0,0 +1,118 @@
package de.hoennig.gittally.commands
import de.hoennig.gittally.build.ArtifactStore
import de.hoennig.gittally.build.BuildExecutor
import de.hoennig.gittally.build.BuildResult
import de.hoennig.gittally.build.BuildResultRepository
import de.hoennig.gittally.build.BuildStatus
import de.hoennig.gittally.build.RunningBuild
import io.kotest.core.spec.style.FunSpec
import io.kotest.matchers.shouldBe
import io.kotest.matchers.string.shouldContain
import io.mockk.clearMocks
import io.mockk.every
import io.mockk.mockk
import java.nio.file.Files
import java.nio.file.Path
import java.time.Duration
import java.time.Instant
class ConsoleBuildRunnerTest : FunSpec() {
private val buildExecutor = mockk<BuildExecutor>()
private val repository = mockk<BuildResultRepository>()
private val artifactStore = mockk<ArtifactStore>()
private lateinit var tempDir: Path
private fun runner() =
ConsoleBuildRunner(buildExecutor, repository, artifactStore).apply {
pollIntervalMillis = 1
persistTimeoutMillis = 100
}
private fun runningBuild(stagingDir: Path) =
RunningBuild(
branch = "main",
commit = "0123456789abcdef",
artifactKey = "main-key",
startedAt = Instant.parse("2026-07-07T10:00:00Z"),
stagingDir = stagingDir,
liveLogFile = stagingDir.resolve(BuildExecutor.LIVE_LOG_FILE),
)
private fun result(
status: BuildStatus,
duration: Duration? = Duration.ofSeconds(83),
) = BuildResult(
branch = "main",
commit = "0123456789abcdef",
status = status,
startedAt = Instant.parse("2026-07-07T10:00:00Z"),
duration = duration,
artifactKey = "main-key",
)
init {
beforeEach {
clearMocks(buildExecutor, repository, artifactStore)
tempDir = Files.createTempDirectory("gittally-console-build-test")
}
afterEach {
tempDir.toFile().deleteRecursively()
}
test("streams the live log and reports the final status once the build is terminal") {
val stagingDir = Files.createDirectory(tempDir.resolve("staging"))
val build = runningBuild(stagingDir)
Files.writeString(build.liveLogFile, "compiling ...\ntests green\n")
every { buildExecutor.startBuild("main", "0123456789abcdef", tempDir) } returns build
// the terminal status arrives together with the finished persist (staging gone)
every { repository.history() } answers {
stagingDir.toFile().deleteRecursively()
listOf(result(BuildStatus.SUCCESS))
}
every { artifactStore.artifactDir("main-key") } returns null
var status: BuildStatus? = null
val console = captureConsole { status = runner().buildAndStream("main", "0123456789abcdef", tempDir) }
status shouldBe BuildStatus.SUCCESS
console.stdout shouldContain "compiling ...\ntests green\n"
console.stdout shouldContain "build of branch main: success after 1:23"
}
test("drains the rest of the log from the persisted copy after the staging directory is gone") {
val stagingDir = tempDir.resolve("staging-never-created")
val build = runningBuild(stagingDir)
val persistedDir = Files.createDirectory(tempDir.resolve("persisted"))
Files.writeString(persistedDir.resolve(BuildExecutor.LIVE_LOG_FILE), "full build output\n")
every { buildExecutor.startBuild("main", "0123456789abcdef", tempDir) } returns build
every { repository.history() } returns listOf(result(BuildStatus.FAILED))
every { artifactStore.artifactDir("main-key") } returns persistedDir
var status: BuildStatus? = null
val console = captureConsole { status = runner().buildAndStream("main", "0123456789abcdef", tempDir) }
status shouldBe BuildStatus.FAILED
console.stdout shouldContain "full build output"
console.stdout shouldContain "build of branch main: failed after 1:23"
}
test("a staging directory that never gets persisted only warns after the timeout") {
val stagingDir = Files.createDirectory(tempDir.resolve("staging"))
val build = runningBuild(stagingDir)
Files.writeString(build.liveLogFile, "some output\n")
every { buildExecutor.startBuild("main", "0123456789abcdef", tempDir) } returns build
every { repository.history() } returns listOf(result(BuildStatus.SUCCESS, duration = null))
var status: BuildStatus? = null
val console = captureConsole { status = runner().buildAndStream("main", "0123456789abcdef", tempDir) }
status shouldBe BuildStatus.SUCCESS
console.stdout shouldContain "some output"
console.stdout shouldContain "build of branch main: success"
console.stderr shouldContain "were not persisted"
}
}
}
@@ -0,0 +1,104 @@
package de.hoennig.gittally.commands
import de.hoennig.gittally.build.BuildResult
import de.hoennig.gittally.build.BuildResultRepository
import de.hoennig.gittally.build.BuildStatus
import de.hoennig.gittally.git.GitService
import io.kotest.core.spec.style.FunSpec
import io.kotest.matchers.shouldBe
import io.kotest.matchers.string.shouldContain
import io.mockk.Called
import io.mockk.clearMocks
import io.mockk.every
import io.mockk.justRun
import io.mockk.mockk
import io.mockk.verify
import java.nio.file.Path
import java.nio.file.Paths
import java.time.Instant
class RetryCommandTest : FunSpec() {
private val gitService = mockk<GitService>()
private val repository = mockk<BuildResultRepository>()
private val consoleBuildRunner = mockk<ConsoleBuildRunner>()
private val dir: Path = Paths.get(".")
private fun command() = RetryCommand(gitService, repository, consoleBuildRunner).apply { workingDir = dir }
private fun result(
branch: String,
status: BuildStatus,
) = BuildResult(
branch = branch,
commit = "commit-$branch",
status = status,
startedAt = Instant.parse("2026-07-07T10:00:00Z"),
duration = null,
artifactKey = "$branch-key",
)
init {
beforeEach {
clearMocks(gitService, repository, consoleBuildRunner)
justRun { gitService.fetchOrigin(dir) }
}
test("retries every branch whose latest build failed, but no others") {
every { repository.latestPerBranch() } returns
listOf(
result("main", BuildStatus.FAILED),
result("feature/ok", BuildStatus.SUCCESS),
result("feature/y", BuildStatus.FAILED),
)
every { gitService.originHeadCommit("main", dir) } returns "head-main"
every { gitService.originHeadCommit("feature/y", dir) } returns "head-y"
every { consoleBuildRunner.buildAndStream(any(), any(), dir) } returns BuildStatus.SUCCESS
var exitCode = -1
captureConsole { exitCode = command().call() }
exitCode shouldBe 0
verify { consoleBuildRunner.buildAndStream("main", "head-main", dir) }
verify { consoleBuildRunner.buildAndStream("feature/y", "head-y", dir) }
verify(exactly = 0) { consoleBuildRunner.buildAndStream("feature/ok", any(), dir) }
}
test("exits with code 1 when a retried build fails again") {
every { repository.latestPerBranch() } returns listOf(result("main", BuildStatus.FAILED))
every { gitService.originHeadCommit("main", dir) } returns "head-main"
every { consoleBuildRunner.buildAndStream("main", "head-main", dir) } returns BuildStatus.FAILED
var exitCode = -1
captureConsole { exitCode = command().call() }
exitCode shouldBe 1
}
test("skips failed branches that are gone from origin") {
every { repository.latestPerBranch() } returns listOf(result("gone", BuildStatus.FAILED))
every { gitService.originHeadCommit("gone", dir) } returns null
var exitCode = -1
val console = captureConsole { exitCode = command().call() }
exitCode shouldBe 0
console.stdout shouldContain "skipping branch gone: gone from origin"
verify { consoleBuildRunner wasNot Called }
}
test("prints a hint when there is nothing to retry") {
every { repository.latestPerBranch() } returns
listOf(
result("main", BuildStatus.SUCCESS),
result("feature/x", BuildStatus.INTERRUPTED),
)
var exitCode = -1
val console = captureConsole { exitCode = command().call() }
exitCode shouldBe 0
console.stdout shouldContain "no failed builds to retry"
verify { consoleBuildRunner wasNot Called }
}
}
}
@@ -0,0 +1,87 @@
package de.hoennig.gittally.commands
import de.hoennig.gittally.build.BuildResult
import de.hoennig.gittally.build.BuildResultRepository
import de.hoennig.gittally.build.BuildStatus
import io.kotest.core.spec.style.FunSpec
import io.kotest.matchers.shouldBe
import io.kotest.matchers.string.shouldContain
import io.kotest.matchers.string.shouldNotContain
import io.mockk.clearMocks
import io.mockk.every
import io.mockk.mockk
import io.mockk.verify
import java.time.Duration
import java.time.Instant
class StatusCommandTest : FunSpec() {
private val repository = mockk<BuildResultRepository>()
private fun result(
branch: String,
status: BuildStatus,
duration: Duration? = Duration.ofSeconds(83),
) = BuildResult(
branch = branch,
commit = "0123456789abcdef0123456789abcdef01234567",
status = status,
startedAt = Instant.parse("2026-07-07T10:00:00Z"),
duration = duration,
artifactKey = "$branch-key",
)
init {
beforeEach {
clearMocks(repository)
}
test("prints the latest build per branch as a table with short commits and legacy duration format") {
every { repository.latestPerBranch() } returns
listOf(
result("main", BuildStatus.SUCCESS),
result("feature/x", BuildStatus.FAILED, duration = null),
)
var exitCode = -1
val console = captureConsole { exitCode = StatusCommand(repository).call() }
exitCode shouldBe 0
console.stdout shouldContain "BRANCH"
console.stdout shouldContain "DURATION"
console.stdout shouldContain "main"
console.stdout shouldContain "feature/x"
console.stdout shouldContain "success"
console.stdout shouldContain "failed"
console.stdout shouldContain "0123456789ab"
console.stdout shouldNotContain "0123456789abc"
console.stdout shouldContain "1:23"
}
test("--history prints all recorded builds instead of only the latest per branch") {
every { repository.history() } returns
listOf(
result("main", BuildStatus.SUCCESS),
result("main", BuildStatus.FAILED),
)
val command = StatusCommand(repository).apply { history = true }
var exitCode = -1
val console = captureConsole { exitCode = command.call() }
exitCode shouldBe 0
console.stdout shouldContain "success"
console.stdout shouldContain "failed"
verify { repository.history() }
}
test("prints a hint when no builds are recorded yet") {
every { repository.latestPerBranch() } returns emptyList()
var exitCode = -1
val console = captureConsole { exitCode = StatusCommand(repository).call() }
exitCode shouldBe 0
console.stdout shouldContain "(no builds recorded)"
}
}
}
@@ -217,6 +217,13 @@ class GitServiceTest : FunSpec() {
service.headCommit(fixture.work) shouldMatch Regex("[0-9a-f]{40}")
}
test("localHeadCommit returns the local branch head, or null for an unknown branch") {
val fixture = Fixture()
service.localHeadCommit("main", fixture.work) shouldBe service.headCommit(fixture.work)
service.localHeadCommit("no-such-branch", fixture.work).shouldBeNull()
}
test("originHeadCommit returns the origin branch head, or null for an unknown branch") {
val fixture = Fixture()