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:
@@ -1,8 +1,11 @@
|
||||
package de.hoennig.gittally
|
||||
|
||||
import de.hoennig.gittally.commands.BuildCommand
|
||||
import de.hoennig.gittally.commands.ConfigPrintCommand
|
||||
import de.hoennig.gittally.commands.InitCommand
|
||||
import de.hoennig.gittally.commands.RetryCommand
|
||||
import de.hoennig.gittally.commands.ServerCommand
|
||||
import de.hoennig.gittally.commands.StatusCommand
|
||||
import org.springframework.stereotype.Component
|
||||
import picocli.CommandLine
|
||||
import picocli.CommandLine.Command
|
||||
@@ -10,7 +13,14 @@ import picocli.CommandLine.Command
|
||||
@Component
|
||||
@Command(
|
||||
name = "gittally",
|
||||
subcommands = [InitCommand::class, ServerCommand::class, ConfigPrintCommand::class],
|
||||
subcommands = [
|
||||
InitCommand::class,
|
||||
ServerCommand::class,
|
||||
StatusCommand::class,
|
||||
BuildCommand::class,
|
||||
RetryCommand::class,
|
||||
ConfigPrintCommand::class,
|
||||
],
|
||||
mixinStandardHelpOptions = true,
|
||||
description = ["Lightweight, declarative CI/CD system"],
|
||||
)
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
package de.hoennig.gittally.commands
|
||||
|
||||
/**
|
||||
* Port of the legacy `resolve_branch_name` partial-name matching: a branch-name
|
||||
* fragment resolves against the local and origin branch names. An exact name always
|
||||
* wins; otherwise a unique substring match resolves, and multiple matches are
|
||||
* reported back (the legacy script prompted interactively instead — a one-shot
|
||||
* CLI command cannot).
|
||||
*/
|
||||
object BranchNameResolution {
|
||||
sealed interface Match
|
||||
|
||||
data class Resolved(
|
||||
val branch: String,
|
||||
) : Match
|
||||
|
||||
data class Ambiguous(
|
||||
val candidates: List<String>,
|
||||
) : Match
|
||||
|
||||
data object NoMatch : Match
|
||||
|
||||
fun resolve(
|
||||
fragment: String,
|
||||
candidates: List<String>,
|
||||
): Match {
|
||||
if (fragment in candidates) {
|
||||
return Resolved(fragment)
|
||||
}
|
||||
val matches = candidates.filter { fragment in it }
|
||||
return when (matches.size) {
|
||||
0 -> NoMatch
|
||||
1 -> Resolved(matches.single())
|
||||
else -> Ambiguous(matches)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
package de.hoennig.gittally.commands
|
||||
|
||||
import de.hoennig.gittally.build.BuildStatus
|
||||
import de.hoennig.gittally.git.GitService
|
||||
import org.springframework.stereotype.Component
|
||||
import picocli.CommandLine.Command
|
||||
import picocli.CommandLine.ExitCode
|
||||
import picocli.CommandLine.Parameters
|
||||
import java.nio.file.Path
|
||||
import java.nio.file.Paths
|
||||
import java.util.concurrent.Callable
|
||||
|
||||
/**
|
||||
* One-shot build for interactive use, replacing the legacy `--stay` mode and explicit
|
||||
* branch arguments: fetch, build the branch in its worktree, stream the log, exit
|
||||
* with the build's outcome. Unlike the watcher it builds even without new commits.
|
||||
*/
|
||||
@Component
|
||||
@Command(
|
||||
name = "build",
|
||||
description = ["Fetch and build a branch once, streaming the build log"],
|
||||
mixinStandardHelpOptions = true,
|
||||
)
|
||||
class BuildCommand(
|
||||
private val gitService: GitService,
|
||||
private val consoleBuildRunner: ConsoleBuildRunner,
|
||||
) : Callable<Int> {
|
||||
@Parameters(
|
||||
index = "0",
|
||||
arity = "0..1",
|
||||
paramLabel = "<branch>",
|
||||
description = ["branch to build; a unique name fragment resolves (default: the current branch)"],
|
||||
)
|
||||
var branchFragment: String? = null
|
||||
|
||||
var workingDir: Path = Paths.get(".")
|
||||
|
||||
override fun call(): Int {
|
||||
val branch: String
|
||||
val commit: String
|
||||
try {
|
||||
fetchBestEffort()
|
||||
branch = resolveBranch() ?: return ExitCode.USAGE
|
||||
commit = commitToBuild(branch) ?: return ExitCode.USAGE
|
||||
} catch (e: Exception) {
|
||||
System.err.println("error: ${e.message}")
|
||||
return ExitCode.USAGE
|
||||
}
|
||||
println("building branch $branch at commit ${commit.take(12)}")
|
||||
val status = consoleBuildRunner.buildAndStream(branch, commit, workingDir)
|
||||
return if (status == BuildStatus.SUCCESS) ExitCode.OK else ExitCode.SOFTWARE
|
||||
}
|
||||
|
||||
/** A one-shot build should still work offline, from the last fetched origin state. */
|
||||
private fun fetchBestEffort() {
|
||||
try {
|
||||
gitService.fetchOrigin(workingDir)
|
||||
} catch (e: Exception) {
|
||||
System.err.println("warning: fetching origin failed (${e.message}); using the last known origin state")
|
||||
}
|
||||
}
|
||||
|
||||
private fun resolveBranch(): String? {
|
||||
val fragment = branchFragment
|
||||
if (fragment == null) {
|
||||
val current = gitService.currentBranch(workingDir)
|
||||
if (current == null) {
|
||||
System.err.println("error: HEAD is detached; specify a branch")
|
||||
}
|
||||
return current
|
||||
}
|
||||
val candidates = (gitService.localBranches(workingDir) + gitService.originBranches(workingDir)).distinct()
|
||||
return when (val match = BranchNameResolution.resolve(fragment, candidates)) {
|
||||
is BranchNameResolution.Resolved -> match.branch
|
||||
is BranchNameResolution.Ambiguous -> {
|
||||
System.err.println("error: multiple branches match '$fragment':")
|
||||
match.candidates.forEach { System.err.println(" $it") }
|
||||
null
|
||||
}
|
||||
BranchNameResolution.NoMatch -> {
|
||||
System.err.println("error: no local or origin branch matches '$fragment'")
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Like the legacy flow: origin's head when the branch has new commits there,
|
||||
* otherwise the local head — so unpushed local commits build as they are.
|
||||
* Origin-only branches build origin's head.
|
||||
*/
|
||||
private fun commitToBuild(branch: String): String? {
|
||||
val localHead = gitService.localHeadCommit(branch, workingDir)
|
||||
val commit =
|
||||
when {
|
||||
localHead == null -> gitService.originHeadCommit(branch, workingDir)
|
||||
gitService.hasNewCommits(branch, workingDir) ->
|
||||
gitService.originHeadCommit(branch, workingDir) ?: localHead
|
||||
else -> localHead
|
||||
}
|
||||
if (commit == null) {
|
||||
System.err.println("error: branch $branch has neither a local nor an origin head commit")
|
||||
}
|
||||
return commit
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
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 de.hoennig.gittally.server.UiFormats
|
||||
import org.springframework.stereotype.Component
|
||||
import java.io.IOException
|
||||
import java.nio.channels.Channels
|
||||
import java.nio.channels.FileChannel
|
||||
import java.nio.file.Files
|
||||
import java.nio.file.Path
|
||||
import java.nio.file.Paths
|
||||
import java.nio.file.StandardOpenOption
|
||||
import java.time.Duration
|
||||
|
||||
/**
|
||||
* Runs one build to completion for a CLI command: enqueues it on the async
|
||||
* [BuildExecutor], streams the live log to stdout while it runs, and waits until the
|
||||
* artifacts are persisted so the CLI exit never cuts off the artifact copy.
|
||||
*/
|
||||
@Component
|
||||
class ConsoleBuildRunner(
|
||||
private val buildExecutor: BuildExecutor,
|
||||
private val repository: BuildResultRepository,
|
||||
private val artifactStore: ArtifactStore,
|
||||
) {
|
||||
var pollIntervalMillis = 200L
|
||||
|
||||
var persistTimeoutMillis = 30_000L
|
||||
|
||||
/** Builds [branch] at [commit], blocking until the build finished; returns the final status. */
|
||||
fun buildAndStream(
|
||||
branch: String,
|
||||
commit: String,
|
||||
workingDir: Path = Paths.get("."),
|
||||
): BuildStatus {
|
||||
val build = buildExecutor.startBuild(branch, commit, workingDir)
|
||||
var printed = 0L
|
||||
var result: BuildResult? = null
|
||||
while (result?.status?.isTerminal != true) {
|
||||
printed += printNewLogBytes(build.liveLogFile, printed)
|
||||
result = repository.history().firstOrNull { it.artifactKey == build.artifactKey }
|
||||
if (result?.status?.isTerminal != true) {
|
||||
Thread.sleep(pollIntervalMillis)
|
||||
}
|
||||
}
|
||||
drainAfterBuild(build, printed)
|
||||
val after = result.duration?.let { " after ${UiFormats.duration(it)}" } ?: ""
|
||||
println("build of branch $branch: ${result.status.name.lowercase()}$after")
|
||||
return result.status
|
||||
}
|
||||
|
||||
/**
|
||||
* The live log is complete once the status is terminal, but the executor still
|
||||
* persists the artifacts afterwards, deleting the staging directory at the end.
|
||||
* Waiting for that keeps the JVM alive until the artifact copy finished; the
|
||||
* remaining log bytes come from the staging file or, once persisted, from the
|
||||
* stored copy (which is byte-identical, so the offset carries over).
|
||||
*/
|
||||
private fun drainAfterBuild(
|
||||
build: RunningBuild,
|
||||
alreadyPrinted: Long,
|
||||
) {
|
||||
var printed = alreadyPrinted
|
||||
val deadline = System.nanoTime() + Duration.ofMillis(persistTimeoutMillis).toNanos()
|
||||
while (Files.exists(build.stagingDir)) {
|
||||
printed += printNewLogBytes(build.liveLogFile, printed)
|
||||
if (System.nanoTime() >= deadline) {
|
||||
System.err.println(
|
||||
"warning: artifacts of ${build.artifactKey} were not persisted within ${persistTimeoutMillis / 1000}s",
|
||||
)
|
||||
return
|
||||
}
|
||||
Thread.sleep(pollIntervalMillis)
|
||||
}
|
||||
artifactStore.artifactDir(build.artifactKey)?.let { artifactDir ->
|
||||
printNewLogBytes(artifactDir.resolve(BuildExecutor.LIVE_LOG_FILE), printed)
|
||||
}
|
||||
}
|
||||
|
||||
/** Copies everything after [offset] to stdout; a missing or vanished file just yields 0 bytes. */
|
||||
private fun printNewLogBytes(
|
||||
file: Path,
|
||||
offset: Long,
|
||||
): Long {
|
||||
if (!Files.isRegularFile(file)) {
|
||||
return 0
|
||||
}
|
||||
return try {
|
||||
FileChannel.open(file, StandardOpenOption.READ).use { channel ->
|
||||
channel.position(offset)
|
||||
val copied = Channels.newInputStream(channel).copyTo(System.out)
|
||||
System.out.flush()
|
||||
copied
|
||||
}
|
||||
} catch (_: IOException) {
|
||||
0
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
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 org.springframework.stereotype.Component
|
||||
import picocli.CommandLine.Command
|
||||
import picocli.CommandLine.ExitCode
|
||||
import java.nio.file.Path
|
||||
import java.nio.file.Paths
|
||||
import java.util.concurrent.Callable
|
||||
|
||||
/**
|
||||
* Port of the legacy `--retry` flag as a one-shot command: every branch whose latest
|
||||
* build FAILED is rebuilt at its origin head, one after the other, streaming each
|
||||
* log. Interrupted and cancelled builds are not retried here — the watcher's startup
|
||||
* recovery restarts those.
|
||||
*/
|
||||
@Component
|
||||
@Command(
|
||||
name = "retry",
|
||||
description = ["Build all branches whose latest build failed"],
|
||||
mixinStandardHelpOptions = true,
|
||||
)
|
||||
class RetryCommand(
|
||||
private val gitService: GitService,
|
||||
private val repository: BuildResultRepository,
|
||||
private val consoleBuildRunner: ConsoleBuildRunner,
|
||||
) : Callable<Int> {
|
||||
var workingDir: Path = Paths.get(".")
|
||||
|
||||
override fun call(): Int {
|
||||
val failed: List<BuildResult>
|
||||
try {
|
||||
fetchBestEffort()
|
||||
failed = repository.latestPerBranch().filter { it.status == BuildStatus.FAILED }
|
||||
} catch (e: Exception) {
|
||||
System.err.println("error: ${e.message}")
|
||||
return ExitCode.USAGE
|
||||
}
|
||||
if (failed.isEmpty()) {
|
||||
println("no failed builds to retry")
|
||||
return ExitCode.OK
|
||||
}
|
||||
var anyFailed = false
|
||||
for (result in failed) {
|
||||
val commit = gitService.originHeadCommit(result.branch, workingDir)
|
||||
if (commit == null) {
|
||||
println("skipping branch ${result.branch}: gone from origin")
|
||||
continue
|
||||
}
|
||||
println("retrying branch ${result.branch} at commit ${commit.take(12)}")
|
||||
val status = consoleBuildRunner.buildAndStream(result.branch, commit, workingDir)
|
||||
if (status != BuildStatus.SUCCESS) {
|
||||
anyFailed = true
|
||||
}
|
||||
}
|
||||
return if (anyFailed) ExitCode.SOFTWARE else ExitCode.OK
|
||||
}
|
||||
|
||||
private fun fetchBestEffort() {
|
||||
try {
|
||||
gitService.fetchOrigin(workingDir)
|
||||
} catch (e: Exception) {
|
||||
System.err.println("warning: fetching origin failed (${e.message}); using the last known origin state")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package de.hoennig.gittally.commands
|
||||
|
||||
import de.hoennig.gittally.build.BuildResult
|
||||
import de.hoennig.gittally.build.BuildResultRepository
|
||||
import de.hoennig.gittally.server.UiFormats
|
||||
import org.springframework.stereotype.Component
|
||||
import picocli.CommandLine.Command
|
||||
import picocli.CommandLine.ExitCode
|
||||
import picocli.CommandLine.Option
|
||||
import java.util.concurrent.Callable
|
||||
|
||||
/**
|
||||
* Reads the result repository directly, so it also works (read-only) while a server
|
||||
* instance is running against the same repository.
|
||||
*/
|
||||
@Component
|
||||
@Command(
|
||||
name = "status",
|
||||
description = ["Print the latest build per branch"],
|
||||
mixinStandardHelpOptions = true,
|
||||
)
|
||||
class StatusCommand(
|
||||
private val repository: BuildResultRepository,
|
||||
) : Callable<Int> {
|
||||
@Option(names = ["--history"], description = ["Print all recorded builds, not only the latest per branch"])
|
||||
var history: Boolean = false
|
||||
|
||||
override fun call(): Int {
|
||||
val results = if (history) repository.history() else repository.latestPerBranch()
|
||||
if (results.isEmpty()) {
|
||||
println("(no builds recorded)")
|
||||
} else {
|
||||
printTable(results)
|
||||
}
|
||||
return ExitCode.OK
|
||||
}
|
||||
|
||||
private fun printTable(results: List<BuildResult>) {
|
||||
val header = listOf("BRANCH", "STATUS", "COMMIT", "TIME", "DURATION")
|
||||
val rows =
|
||||
results.map {
|
||||
listOf(
|
||||
it.branch,
|
||||
it.status.name.lowercase(),
|
||||
it.commit.take(12),
|
||||
UiFormats.timestamp(it.startedAt),
|
||||
UiFormats.duration(it.duration),
|
||||
)
|
||||
}
|
||||
val widths = header.indices.map { column -> (rows + listOf(header)).maxOf { row -> row[column].length } }
|
||||
for (row in listOf(header) + rows) {
|
||||
println(row.mapIndexed { column, cell -> cell.padEnd(widths[column]) }.joinToString(" ").trimEnd())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -151,6 +151,15 @@ class GitService(
|
||||
.trim()
|
||||
.ifEmpty { null }
|
||||
|
||||
/** The commit `refs/heads/[branch]` points at, or null when there is no such local branch. */
|
||||
fun localHeadCommit(
|
||||
branch: String,
|
||||
workingDir: Path = Paths.get("."),
|
||||
): String? {
|
||||
val result = runner.run(listOf("git", "rev-parse", "--verify", "refs/heads/$branch"), workingDir)
|
||||
return if (result.isSuccess) result.stdout.trim() else null
|
||||
}
|
||||
|
||||
/** The commit `refs/remotes/origin/[branch]` points at, or null when the branch is not on origin. */
|
||||
fun originHeadCommit(
|
||||
branch: String,
|
||||
|
||||
@@ -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