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,
|
||||
|
||||
Reference in New Issue
Block a user