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
@@ -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,