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:
@@ -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()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user