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:
@@ -12,6 +12,8 @@
|
||||
|
||||
# Package Files #
|
||||
*.jar
|
||||
# ... but builds in fresh checkouts (e.g. GitTally worktrees) need the wrapper
|
||||
!gradle/wrapper/gradle-wrapper.jar
|
||||
*.war
|
||||
*.nar
|
||||
*.ear
|
||||
|
||||
@@ -38,9 +38,16 @@ GitTallyCommand ← root @Command, delegates to subcommands
|
||||
commands/
|
||||
InitCommand ← "init"
|
||||
ServerCommand ← "server"
|
||||
StatusCommand ← "status [--history]"
|
||||
BuildCommand ← "build [<branch>]"
|
||||
RetryCommand ← "retry"
|
||||
ConfigPrintCommand ← "config:print [--full]"
|
||||
```
|
||||
|
||||
`status`, `build`, and `retry` implement `Callable<Int>` for their exit codes (0 success, 1 build failure, 2 usage/config errors).
|
||||
`build` and `retry` run builds through the async `BuildExecutor` but block until completion via `ConsoleBuildRunner`, which streams the live log to stdout and waits for the artifact persist before the JVM exits.
|
||||
Branch arguments resolve legacy-style name fragments (`BranchNameResolution`); the CLI reuses `UiFormats` so console and web UI display the same formats.
|
||||
|
||||
The web application type is set to `none` in `application.yml`, so plain CLI runs never start a web server. The `server` subcommand launches a **second** `SpringApplication` with `WebApplicationType.SERVLET` and the `server` profile, then blocks until shutdown. `application-server.yml` switches the web type (`spring.main.*` properties beat programmatic builder settings), `CliRunner` is `@Profile("!server")` so the second context does not run picocli again, and the watcher poll loop starts only in the `server` profile (`ServerWatcherLifecycle`). The JSON API, artifact serving, and the web UI live in the `server` package; mutating endpoints are guarded by a generated control token under `.git/gittally/control-token`.
|
||||
|
||||
### Web UI
|
||||
|
||||
@@ -36,3 +36,35 @@ Exit codes: 0 on success, 1 on build failure, 2 on usage/config errors (align wi
|
||||
|
||||
- `./gradlew ktlintFormat` then `./gradlew build` is green.
|
||||
- `java -jar ... status` and `java -jar ... build` work in this repository (manual smoke test; document in this file).
|
||||
|
||||
## Implementation Notes (2026-07-07)
|
||||
|
||||
Implemented as designed: `status`, `build`, and `retry` are picocli `@Component` subcommands in `commands/`, wired into `GitTallyCommand` like the existing ones.
|
||||
They implement `Callable<Int>`, so the exit codes align with `CliRunner`'s `ExitCodeGenerator` contract: 0 on success, 1 on build failure, 2 on usage/config errors (picocli's own `USAGE` code for invalid options matches).
|
||||
|
||||
- `status [--history]` reads `BuildResultRepository` directly and prints an aligned table (branch, status, commit, time, duration); it reuses `UiFormats`, so the console shows the same timestamp/duration formats as the web UI.
|
||||
- `build [<branch>]` fetches best-effort, resolves the branch, enqueues one build on the async `BuildExecutor`, and blocks until it finished.
|
||||
`ConsoleBuildRunner` streams the live log to stdout while the build runs and afterwards waits (up to 30s) until the artifacts are persisted, so the CLI exit never cuts off the artifact copy.
|
||||
- `retry` rebuilds every branch whose latest build is FAILED (like legacy `--retry`) at its origin head, sequentially, streaming each log; branches gone from origin are skipped.
|
||||
|
||||
Deviations and decisions:
|
||||
|
||||
- Branch fragment resolution (`BranchNameResolution`) ports legacy `resolve_branch_name` non-interactively: an exact name wins, a unique substring match resolves, and ambiguity lists the candidates and exits with code 2 — the legacy interactive selection prompt was not ported (a one-shot command should be scriptable).
|
||||
- Commit selection follows the legacy flow without moving local refs: 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.
|
||||
`GitService` gained `localHeadCommit(branch)` for that.
|
||||
- Unlike the watcher (and unlike legacy without new commits), an explicit `build` always builds, even when the commit was already built — that is the point of a one-shot command.
|
||||
- A failed fetch only warns and the commands continue from the last-known origin state, so they work offline.
|
||||
- `retry` only retries FAILED builds (legacy `branch_has_failed_build` checked exactly `failed`); interrupted/pending builds are the watcher's startup-recovery job.
|
||||
- Exit code 130 for cancelled builds was not ported; a cancelled/interrupted build exits 1 like any non-success.
|
||||
- Found while smoke testing: `.gitignore`'s `*.jar` rule excluded `gradle/wrapper/gradle-wrapper.jar`, so builds in fresh checkouts — including every GitTally worktree — failed with `ClassNotFoundException: GradleWrapperMain`.
|
||||
Fixed with a `!gradle/wrapper/gradle-wrapper.jar` exception and by adding the jar (same class of defect as the `build/` rule fixed in step 04).
|
||||
|
||||
Manual smoke test (2026-07-07, in this repository):
|
||||
|
||||
- `status` with no recorded builds printed `(no builds recorded)`, exit 0.
|
||||
- `build no-such-branch` printed `error: no local or origin branch matches 'no-such-branch'`, exit 2.
|
||||
- Fetching origin fails on this machine (no credentials configured); every command printed the warning and continued from the last-known origin state as designed.
|
||||
- The first `build` of the current branch (`main`) exposed the missing wrapper jar: the log streamed, the build failed after 0:00, exit 1 — correct behavior against a broken commit.
|
||||
- After placing the wrapper jar (untracked files survive the worktree's forced checkout, so this mirrors the state once the `.gitignore` fix is committed), `build` streamed the whole Gradle log live and ended with `build of branch main: success after 0:53`, exit 0; both builds' logs and the `reports` dir arrived in the artifact root before the JVM exited.
|
||||
- `retry` after the success printed `no failed builds to retry`, exit 0.
|
||||
- `status` showed one aligned row (`main success a6a2c9de0b97 2026-07-07 13:15 0:53`); `status --history` additionally listed the earlier failed build.
|
||||
|
||||
+1
-1
@@ -60,7 +60,7 @@ Server and UI:
|
||||
|
||||
Completion:
|
||||
|
||||
- [ ] `10-cli-commands.md` — CLI build/status commands
|
||||
- [x] `10-cli-commands.md` — CLI build/status commands
|
||||
- [ ] `11-docker-build-runtime.md` — optional Docker build execution
|
||||
- [ ] `12-deployment.md` — systemd service, migration from legacy, docs
|
||||
|
||||
|
||||
Vendored
BIN
Binary file not shown.
@@ -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