Step 22 C: --repo selects a registered repository in build, retry, and status

The RepoOption mixin resolves the name through the registry; without it a
command means the current directory when served, else the first entry. An
unknown name is a usage error naming the registered repositories.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
mhoennig
2026-09-02 09:03:07 +02:00
co-authored by Claude Fable 5.1
parent a5027acfaf
commit b66d26a03a
7 changed files with 109 additions and 12 deletions
@@ -3,9 +3,11 @@ package de.hoennig.werkator.commands
import de.hoennig.werkator.build.BuildStatus import de.hoennig.werkator.build.BuildStatus
import de.hoennig.werkator.git.GitService import de.hoennig.werkator.git.GitService
import de.hoennig.werkator.repo.RepoContext import de.hoennig.werkator.repo.RepoContext
import de.hoennig.werkator.repo.RepoRegistry
import org.springframework.stereotype.Component import org.springframework.stereotype.Component
import picocli.CommandLine.Command import picocli.CommandLine.Command
import picocli.CommandLine.ExitCode import picocli.CommandLine.ExitCode
import picocli.CommandLine.Mixin
import picocli.CommandLine.Parameters import picocli.CommandLine.Parameters
import java.nio.file.Path import java.nio.file.Path
import java.util.concurrent.Callable import java.util.concurrent.Callable
@@ -24,9 +26,11 @@ import java.util.concurrent.Callable
class BuildCommand( class BuildCommand(
private val gitService: GitService, private val gitService: GitService,
private val consoleBuildRunner: ConsoleBuildRunner, private val consoleBuildRunner: ConsoleBuildRunner,
/** The repository to build: the current working directory (a repo selector comes with the registry). */ private val registry: RepoRegistry,
var repo: RepoContext,
) : Callable<Int> { ) : Callable<Int> {
@Mixin
var repoOption = RepoOption()
@Parameters( @Parameters(
index = "0", index = "0",
arity = "0..1", arity = "0..1",
@@ -35,6 +39,8 @@ class BuildCommand(
) )
var branchFragment: String? = null var branchFragment: String? = null
private lateinit var repo: RepoContext
private val workingDir: Path private val workingDir: Path
get() = repo.workingDir get() = repo.workingDir
@@ -42,6 +48,7 @@ class BuildCommand(
val branch: String val branch: String
val commit: String val commit: String
try { try {
repo = repoOption.select(registry)
fetchBestEffort() fetchBestEffort()
branch = resolveBranch() ?: return ExitCode.USAGE branch = resolveBranch() ?: return ExitCode.USAGE
commit = commitToBuild(branch) ?: return ExitCode.USAGE commit = commitToBuild(branch) ?: return ExitCode.USAGE
@@ -0,0 +1,28 @@
package de.hoennig.werkator.commands
import de.hoennig.werkator.repo.RepoContext
import de.hoennig.werkator.repo.RepoRegistry
import picocli.CommandLine.Option
/**
* The `--repo` selector of the repository-scoped commands (ADR 0009): names an entry
* of the instance registry. Without it a command means the current working directory
* when that is served, otherwise the first registered repository — so inside a
* repository every command behaves exactly as it did with one.
*/
class RepoOption {
@Option(
names = ["--repo"],
paramLabel = "<name>",
description = ["registered repository to act on (default: the current directory)"],
)
var name: String? = null
fun select(registry: RepoRegistry): RepoContext {
val wanted = name?.trim()?.takeIf { it.isNotEmpty() } ?: return registry.current()
return registry.byName(wanted)
?: throw IllegalArgumentException(
"no repository named '$wanted' is registered (registered: ${registry.all().joinToString(", ") { it.name }})",
)
}
}
@@ -4,9 +4,11 @@ import de.hoennig.werkator.build.BuildResult
import de.hoennig.werkator.build.BuildStatus import de.hoennig.werkator.build.BuildStatus
import de.hoennig.werkator.git.GitService import de.hoennig.werkator.git.GitService
import de.hoennig.werkator.repo.RepoContext import de.hoennig.werkator.repo.RepoContext
import de.hoennig.werkator.repo.RepoRegistry
import org.springframework.stereotype.Component import org.springframework.stereotype.Component
import picocli.CommandLine.Command import picocli.CommandLine.Command
import picocli.CommandLine.ExitCode import picocli.CommandLine.ExitCode
import picocli.CommandLine.Mixin
import java.nio.file.Path import java.nio.file.Path
import java.util.concurrent.Callable import java.util.concurrent.Callable
@@ -25,15 +27,20 @@ import java.util.concurrent.Callable
class RetryCommand( class RetryCommand(
private val gitService: GitService, private val gitService: GitService,
private val consoleBuildRunner: ConsoleBuildRunner, private val consoleBuildRunner: ConsoleBuildRunner,
/** The repository to retry in: the current working directory (a repo selector comes with the registry). */ private val registry: RepoRegistry,
var repo: RepoContext,
) : Callable<Int> { ) : Callable<Int> {
@Mixin
var repoOption = RepoOption()
private lateinit var repo: RepoContext
private val workingDir: Path private val workingDir: Path
get() = repo.workingDir get() = repo.workingDir
override fun call(): Int { override fun call(): Int {
val failed: List<BuildResult> val failed: List<BuildResult>
try { try {
repo = repoOption.select(registry)
fetchBestEffort() fetchBestEffort()
failed = repo.results.latestPerName().filter { it.status == BuildStatus.FAILED } failed = repo.results.latestPerName().filter { it.status == BuildStatus.FAILED }
} catch (e: Exception) { } catch (e: Exception) {
@@ -1,11 +1,12 @@
package de.hoennig.werkator.commands package de.hoennig.werkator.commands
import de.hoennig.werkator.build.BuildResult import de.hoennig.werkator.build.BuildResult
import de.hoennig.werkator.build.BuildResultRepository import de.hoennig.werkator.repo.RepoRegistry
import de.hoennig.werkator.server.UiFormats import de.hoennig.werkator.server.UiFormats
import org.springframework.stereotype.Component import org.springframework.stereotype.Component
import picocli.CommandLine.Command import picocli.CommandLine.Command
import picocli.CommandLine.ExitCode import picocli.CommandLine.ExitCode
import picocli.CommandLine.Mixin
import picocli.CommandLine.Option import picocli.CommandLine.Option
import java.util.concurrent.Callable import java.util.concurrent.Callable
@@ -20,12 +21,22 @@ import java.util.concurrent.Callable
mixinStandardHelpOptions = true, mixinStandardHelpOptions = true,
) )
class StatusCommand( class StatusCommand(
private val repository: BuildResultRepository, private val registry: RepoRegistry,
) : Callable<Int> { ) : Callable<Int> {
@Option(names = ["--history"], description = ["Print all recorded builds, not only the latest per branch"]) @Option(names = ["--history"], description = ["Print all recorded builds, not only the latest per branch"])
var history: Boolean = false var history: Boolean = false
@Mixin
var repoOption = RepoOption()
override fun call(): Int { override fun call(): Int {
val repository =
try {
repoOption.select(registry).results
} catch (e: IllegalArgumentException) {
System.err.println("error: ${e.message}")
return ExitCode.USAGE
}
val results = if (history) repository.history() else repository.latestPerName() val results = if (history) repository.history() else repository.latestPerName()
if (results.isEmpty()) { if (results.isEmpty()) {
println("(no builds recorded)") println("(no builds recorded)")
@@ -3,6 +3,7 @@ package de.hoennig.werkator.commands
import de.hoennig.werkator.build.BuildStatus import de.hoennig.werkator.build.BuildStatus
import de.hoennig.werkator.git.GitService import de.hoennig.werkator.git.GitService
import de.hoennig.werkator.repo.RepoContext import de.hoennig.werkator.repo.RepoContext
import de.hoennig.werkator.repo.RepoRegistry
import io.kotest.core.spec.style.FunSpec import io.kotest.core.spec.style.FunSpec
import io.kotest.matchers.shouldBe import io.kotest.matchers.shouldBe
import io.kotest.matchers.string.shouldContain import io.kotest.matchers.string.shouldContain
@@ -20,9 +21,10 @@ class BuildCommandTest : FunSpec() {
private val consoleBuildRunner = mockk<ConsoleBuildRunner>() private val consoleBuildRunner = mockk<ConsoleBuildRunner>()
private val dir: Path = Paths.get(".") private val dir: Path = Paths.get(".")
private val repo = RepoContext("test", dir, mockk(), mockk()) private val repo = RepoContext("test", dir, mockk(), mockk())
private val registry = mockk<RepoRegistry>().also { every { it.current() } returns repo }
private fun command(fragment: String? = null) = private fun command(fragment: String? = null) =
BuildCommand(gitService, consoleBuildRunner, repo).apply { BuildCommand(gitService, consoleBuildRunner, registry).apply {
branchFragment = fragment branchFragment = fragment
} }
@@ -5,6 +5,7 @@ import de.hoennig.werkator.build.BuildResultRepository
import de.hoennig.werkator.build.BuildStatus import de.hoennig.werkator.build.BuildStatus
import de.hoennig.werkator.git.GitService import de.hoennig.werkator.git.GitService
import de.hoennig.werkator.repo.RepoContext import de.hoennig.werkator.repo.RepoContext
import de.hoennig.werkator.repo.RepoRegistry
import io.kotest.core.spec.style.FunSpec import io.kotest.core.spec.style.FunSpec
import io.kotest.matchers.shouldBe import io.kotest.matchers.shouldBe
import io.kotest.matchers.string.shouldContain import io.kotest.matchers.string.shouldContain
@@ -24,8 +25,9 @@ class RetryCommandTest : FunSpec() {
private val consoleBuildRunner = mockk<ConsoleBuildRunner>() private val consoleBuildRunner = mockk<ConsoleBuildRunner>()
private val dir: Path = Paths.get(".") private val dir: Path = Paths.get(".")
private val repo = RepoContext("test", dir, repository, mockk()) private val repo = RepoContext("test", dir, repository, mockk())
private val registry = mockk<RepoRegistry>().also { every { it.current() } returns repo }
private fun command() = RetryCommand(gitService, consoleBuildRunner, repo) private fun command() = RetryCommand(gitService, consoleBuildRunner, registry)
private fun result( private fun result(
branch: String, branch: String,
@@ -3,6 +3,8 @@ package de.hoennig.werkator.commands
import de.hoennig.werkator.build.BuildResult import de.hoennig.werkator.build.BuildResult
import de.hoennig.werkator.build.BuildResultRepository import de.hoennig.werkator.build.BuildResultRepository
import de.hoennig.werkator.build.BuildStatus import de.hoennig.werkator.build.BuildStatus
import de.hoennig.werkator.repo.RepoContext
import de.hoennig.werkator.repo.RepoRegistry
import io.kotest.core.spec.style.FunSpec import io.kotest.core.spec.style.FunSpec
import io.kotest.matchers.shouldBe import io.kotest.matchers.shouldBe
import io.kotest.matchers.string.shouldContain import io.kotest.matchers.string.shouldContain
@@ -16,6 +18,30 @@ import java.time.Instant
class StatusCommandTest : FunSpec() { class StatusCommandTest : FunSpec() {
private val repository = mockk<BuildResultRepository>() private val repository = mockk<BuildResultRepository>()
private val other = mockk<BuildResultRepository>()
private val registry =
mockk<RepoRegistry>().also {
val current =
RepoContext(
"current",
java.nio.file.Paths
.get("."),
repository,
mockk(),
)
val second =
RepoContext(
"second",
java.nio.file.Paths
.get("second"),
other,
mockk(),
)
every { it.current() } returns current
every { it.all() } returns listOf(current, second)
every { it.byName("second") } returns second
every { it.byName("nope") } returns null
}
private fun result( private fun result(
branch: String, branch: String,
@@ -32,7 +58,21 @@ class StatusCommandTest : FunSpec() {
init { init {
beforeEach { beforeEach {
clearMocks(repository) clearMocks(repository, other)
}
test("--repo selects a registered repository; an unknown name is a usage error naming the registered ones") {
every { other.latestPerName() } returns listOf(result("main", BuildStatus.SUCCESS))
var exitCode = -1
val console = captureConsole { exitCode = StatusCommand(registry).apply { repoOption.name = "second" }.call() }
exitCode shouldBe 0
console.stdout shouldContain "main"
verify(exactly = 0) { repository.latestPerName() }
val failed = captureConsole { exitCode = StatusCommand(registry).apply { repoOption.name = "nope" }.call() }
exitCode shouldBe 2
failed.stderr shouldContain "current, second"
} }
test("prints the latest build per branch as a table with short commits and legacy duration format") { test("prints the latest build per branch as a table with short commits and legacy duration format") {
@@ -43,7 +83,7 @@ class StatusCommandTest : FunSpec() {
) )
var exitCode = -1 var exitCode = -1
val console = captureConsole { exitCode = StatusCommand(repository).call() } val console = captureConsole { exitCode = StatusCommand(registry).call() }
exitCode shouldBe 0 exitCode shouldBe 0
console.stdout shouldContain "BRANCH" console.stdout shouldContain "BRANCH"
@@ -64,7 +104,7 @@ class StatusCommandTest : FunSpec() {
result("main", BuildStatus.FAILED), result("main", BuildStatus.FAILED),
) )
val command = StatusCommand(repository).apply { history = true } val command = StatusCommand(registry).apply { history = true }
var exitCode = -1 var exitCode = -1
val console = captureConsole { exitCode = command.call() } val console = captureConsole { exitCode = command.call() }
@@ -78,7 +118,7 @@ class StatusCommandTest : FunSpec() {
every { repository.latestPerName() } returns emptyList() every { repository.latestPerName() } returns emptyList()
var exitCode = -1 var exitCode = -1
val console = captureConsole { exitCode = StatusCommand(repository).call() } val console = captureConsole { exitCode = StatusCommand(registry).call() }
exitCode shouldBe 0 exitCode shouldBe 0
console.stdout shouldContain "(no builds recorded)" console.stdout shouldContain "(no builds recorded)"