reintroduced legacy Branches view: added /branches endpoint for listing origin branches and their latest builds (or unknown for never-built branches), updated UI with reload button and navigation, and enhanced API and tests

This commit is contained in:
Michael Hoennig
2026-07-07 15:16:01 +02:00
parent f1f5946ac4
commit a659cd6764
14 changed files with 310 additions and 9 deletions
+8
View File
@@ -72,3 +72,11 @@ After `git push`, the open Latest tab showed the new build without reload and it
`/current` showed the build card with streaming live log, ticking duration, and cancel button; on completion the card flipped to `finished` with a working link to the artifact page (status badge, build command, three log links, `reports/demo/index.html` served with no-cache headers). `/current` showed the build card with streaming live log, ticking duration, and cancel button; on completion the card flipped to `finished` with a working link to the artifact page (status badge, build command, three log links, `reports/demo/index.html` served with no-cache headers).
Killing the server flipped the indicator to a red `error` badge (fetch failure in the tooltip) and dimmed the stale table — zero spinners (verified through a TCP proxy so the tab outlived the process); restarting the server brought `live` back. Killing the server flipped the indicator to a red `error` badge (fetch failure in the tooltip) and dimmed the stale table — zero spinners (verified through a TCP proxy so the tab outlived the process); restarting the server brought `live` back.
The 375px viewport stacked rows as labeled cards, and the History view correctly offers delete but no restart. The 375px viewport stacked rows as labeled cards, and the History view correctly offers delete but no restart.
Addendum (2026-07-07, after step 12): the legacy Branches view had not been ported; it was added later on request.
`/branches` (nav: Latest | Branches | History | Current | System) lists every branch with its latest build, or an `unknown` row when never built — main/master first, then flat names, then hierarchical names, like legacy.
Deviation: the listing enumerates origin branches instead of legacy's local branches, because the new watcher's branch universe is origin (local refs never move and origin-only branches do get built).
`POST /api/builds/restart` falls back to the branch's origin head when it has no recorded build, so the Branches view can trigger first builds like legacy.
Addendum (2026-07-07): the legacy per-page reload button (`⟳`, top right) was also re-added on request, next to the live indicator.
On polling pages it triggers an immediate data refresh via the page's poller; pages without a poller (artifact index) reload fully.
@@ -52,6 +52,17 @@ class GitService(
.lines() .lines()
.filter { it != "HEAD" } .filter { it != "HEAD" }
/** All origin branches with their head commit, in one git call; refnames cannot contain spaces. */
fun originBranchHeads(workingDir: Path = Paths.get(".")): Map<String, String> =
runner
.runOrThrow(
listOf("git", "for-each-ref", "--format=%(refname:strip=3) %(objectname)", "refs/remotes/origin"),
workingDir,
).lines()
.map { it.substringBeforeLast(' ') to it.substringAfterLast(' ') }
.filter { (branch, _) -> branch != "HEAD" }
.toMap()
/** /**
* A branch has new commits when its origin counterpart is ahead of the local branch, * A branch has new commits when its origin counterpart is ahead of the local branch,
* or when it exists only on origin. * or when it exists only on origin.
@@ -29,6 +29,45 @@ data class BuildResultDto(
} }
} }
/**
* One entry of `GET /api/branches`, like a legacy branches-view row: an origin
* branch with its latest build, or an `unknown` placeholder when never built.
*/
data class BranchDto(
val branch: String,
val commit: String,
val status: String,
val startedAt: Instant?,
val durationSeconds: Long?,
val artifactKey: String,
) {
companion object {
fun from(
branch: String,
headCommit: String,
latest: BuildResult?,
) = if (latest == null) {
BranchDto(
branch = branch,
commit = headCommit,
status = CommitStatusDto.UNKNOWN_STATUS,
startedAt = null,
durationSeconds = null,
artifactKey = "",
)
} else {
BranchDto(
branch = branch,
commit = latest.commit,
status = latest.status.jsonName,
startedAt = latest.startedAt,
durationSeconds = latest.duration?.seconds,
artifactKey = latest.artifactKey,
)
}
}
}
/** One entry of `GET /api/builds/current`; the log grows while the build runs. */ /** One entry of `GET /api/builds/current`; the log grows while the build runs. */
data class CurrentBuildDto( data class CurrentBuildDto(
val branch: String, val branch: String,
@@ -0,0 +1,34 @@
package de.hoennig.gittally.server
import de.hoennig.gittally.build.BuildResultRepository
import de.hoennig.gittally.git.GitService
import org.springframework.stereotype.Component
import java.nio.file.Path
import java.nio.file.Paths
/**
* The branches-view data, shared by the JSON API and the server-rendered page:
* every origin branch joined with its latest build (or an `unknown` placeholder
* when never built), ordered like the legacy branches view — main/master first,
* then flat names, then hierarchical names, alphabetical within each group.
* Legacy listed local branches; the new watcher's branch universe is origin.
*/
@Component
class BranchListing(
private val gitService: GitService,
private val repository: BuildResultRepository,
) {
fun branches(workingDir: Path = Paths.get(".")): List<BranchDto> =
gitService
.originBranchHeads(workingDir)
.entries
.sortedWith(compareBy({ sortGroup(it.key) }, { it.key }))
.map { (branch, headCommit) -> BranchDto.from(branch, headCommit, repository.latestFor(branch)) }
private fun sortGroup(branch: String): Int =
when {
branch == "main" || branch == "master" -> 0
'/' !in branch -> 1
else -> 2
}
}
@@ -4,6 +4,7 @@ import de.hoennig.gittally.build.ArtifactStore
import de.hoennig.gittally.build.BuildExecutor import de.hoennig.gittally.build.BuildExecutor
import de.hoennig.gittally.build.BuildResultRepository import de.hoennig.gittally.build.BuildResultRepository
import de.hoennig.gittally.build.BuildStatus import de.hoennig.gittally.build.BuildStatus
import de.hoennig.gittally.git.GitService
import org.springframework.http.HttpStatus import org.springframework.http.HttpStatus
import org.springframework.http.ResponseEntity import org.springframework.http.ResponseEntity
import org.springframework.web.bind.annotation.DeleteMapping import org.springframework.web.bind.annotation.DeleteMapping
@@ -17,6 +18,7 @@ import java.nio.ByteBuffer
import java.nio.channels.FileChannel import java.nio.channels.FileChannel
import java.nio.file.Files import java.nio.file.Files
import java.nio.file.Path import java.nio.file.Path
import java.nio.file.Paths
import java.nio.file.StandardOpenOption import java.nio.file.StandardOpenOption
/** /**
@@ -30,10 +32,18 @@ class BuildsApiController(
private val buildExecutor: BuildExecutor, private val buildExecutor: BuildExecutor,
private val artifactStore: ArtifactStore, private val artifactStore: ArtifactStore,
private val controlTokens: ControlTokenService, private val controlTokens: ControlTokenService,
private val gitService: GitService,
private val branchListing: BranchListing,
) { ) {
var workingDir: Path = Paths.get(".")
@GetMapping("/api/builds/latest") @GetMapping("/api/builds/latest")
fun latest(): List<BuildResultDto> = repository.latestPerBranch().map { BuildResultDto.from(it) } fun latest(): List<BuildResultDto> = repository.latestPerBranch().map { BuildResultDto.from(it) }
/** The legacy branches view: every origin branch with its latest build or `unknown`. */
@GetMapping("/api/branches")
fun branches(): List<BranchDto> = branchListing.branches(workingDir)
@GetMapping("/api/builds/history") @GetMapping("/api/builds/history")
fun history(): List<BuildResultDto> = repository.history().map { BuildResultDto.from(it) } fun history(): List<BuildResultDto> = repository.history().map { BuildResultDto.from(it) }
@@ -68,7 +78,8 @@ class BuildsApiController(
} }
/** /**
* Re-enqueues the branch's last recorded commit, like the legacy `/control/restart`. * Re-enqueues the branch's last recorded commit — or its origin head for a branch
* never built, so the branches view can trigger first builds like legacy.
* The branch is a parameter, not a path variable, because branch names may contain * The branch is a parameter, not a path variable, because branch names may contain
* slashes (Tomcat rejects encoded slashes in the path by default). * slashes (Tomcat rejects encoded slashes in the path by default).
*/ */
@@ -79,10 +90,11 @@ class BuildsApiController(
@RequestParam(name = "token", required = false) paramToken: String?, @RequestParam(name = "token", required = false) paramToken: String?,
): ResponseEntity<Any> { ): ResponseEntity<Any> {
rejectBadToken(headerToken ?: paramToken)?.let { return it } rejectBadToken(headerToken ?: paramToken)?.let { return it }
val latest = val commit =
repository.latestFor(branch) repository.latestFor(branch)?.commit
?: return notFound("branch '$branch' has no recorded build") ?: gitService.originHeadCommit(branch, workingDir)
val running = buildExecutor.startBuild(branch, latest.commit) ?: return notFound("branch '$branch' has no recorded build and no origin counterpart")
val running = buildExecutor.startBuild(branch, commit)
return ResponseEntity.accepted().body( return ResponseEntity.accepted().body(
BuildResultDto( BuildResultDto(
branch = running.branch, branch = running.branch,
@@ -34,6 +34,7 @@ class UiController(
private val controlTokens: ControlTokenService, private val controlTokens: ControlTokenService,
private val configLoader: ConfigLoader, private val configLoader: ConfigLoader,
private val metricsCollector: SystemMetricsCollector, private val metricsCollector: SystemMetricsCollector,
private val branchListing: BranchListing,
private val buildProperties: ObjectProvider<BuildProperties>, private val buildProperties: ObjectProvider<BuildProperties>,
) { ) {
var workingDir: Path = Paths.get(".") var workingDir: Path = Paths.get(".")
@@ -48,6 +49,17 @@ class UiController(
return "builds" return "builds"
} }
/** The legacy branches view: every origin branch with its latest build or an `unknown` row. */
@GetMapping("/branches")
fun branches(model: Model): String {
val links = baseModel(model, view = "branches", pageTitle = "Branches")
model.addAttribute("rows", branchListing.branches(workingDir).map { BuildRowView.from(it, links) })
model.addAttribute("apiPath", "/api/branches")
model.addAttribute("allowRestart", true)
model.addAttribute("emptyMessage", "No branches found on origin.")
return "builds"
}
@GetMapping("/history") @GetMapping("/history")
fun history(model: Model): String { fun history(model: Model): String {
val links = baseModel(model, view = "history", pageTitle = "Build History") val links = baseModel(model, view = "history", pageTitle = "Build History")
@@ -96,6 +96,23 @@ data class BuildRowView(
branchUrl = links.branchUrl(result.branch), branchUrl = links.branchUrl(result.branch),
commitUrl = links.commitUrl(result.commit), commitUrl = links.commitUrl(result.commit),
) )
/** A branches-view row; never-built branches have no timestamps and no artifact. */
fun from(
entry: BranchDto,
links: GiteaWebLinks,
) = BuildRowView(
branch = entry.branch,
commit = entry.commit,
commitAbbrev = entry.commit.take(12),
status = entry.status,
startedAtIso = entry.startedAt?.toString() ?: "",
startedAt = entry.startedAt?.let { UiFormats.timestamp(it) } ?: "",
duration = UiFormats.duration(entry.durationSeconds?.let { Duration.ofSeconds(it) }),
artifactKey = entry.artifactKey,
branchUrl = links.branchUrl(entry.branch),
commitUrl = links.commitUrl(entry.commit),
)
} }
} }
+5 -1
View File
@@ -66,7 +66,11 @@ a:hover { text-decoration: underline; }
/* view toggle nav + live indicator */ /* view toggle nav + live indicator */
.view-row { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin: 0 0 18px; } .view-row { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin: 0 0 18px; }
.view-row-actions { margin-left: auto; } .view-row-actions { margin-left: auto; display: inline-flex; align-items: center; gap: 8px; }
.reload-button { appearance: none; display: inline-flex; align-items: center; justify-content: center; width: 30px; height: 30px; border: 1px solid var(--border); border-radius: 6px; background: var(--panel); color: var(--link); font: 18px/1 system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; cursor: pointer; }
.reload-button:hover { background: color-mix(in srgb, var(--link) 8%, transparent); }
.reload-button.is-reloading { animation: reload-spin 0.6s ease-out; }
@keyframes reload-spin { to { transform: rotate(360deg); } }
.view-toggle { display: inline-flex; gap: 0; border: 1px solid var(--border); border-radius: 8px; overflow: hidden; background: var(--panel); } .view-toggle { display: inline-flex; gap: 0; border: 1px solid var(--border); border-radius: 8px; overflow: hidden; background: var(--panel); }
.view-toggle a, .view-toggle span { display: inline-flex; min-width: 88px; justify-content: center; padding: 7px 12px; font-weight: 700; } .view-toggle a, .view-toggle span { display: inline-flex; min-width: 88px; justify-content: center; padding: 7px 12px; font-weight: 700; }
.view-toggle span { background: var(--link); color: white; } .view-toggle span { background: var(--link); color: white; }
+20
View File
@@ -482,10 +482,30 @@ document.addEventListener("click", async (event) => {
} }
}); });
// ---- reload button -------------------------------------------------------------
/** Refreshes via the page's poller; pages without one (e.g. artifact index) reload fully. */
function initReloadButton() {
const button = document.getElementById("reload-button");
if (!button) {
return;
}
button.addEventListener("animationend", () => button.classList.remove("is-reloading"));
button.addEventListener("click", () => {
button.classList.add("is-reloading");
if (refreshNow) {
refreshNow();
} else {
window.location.reload();
}
});
}
// ---- page wiring --------------------------------------------------------------- // ---- page wiring ---------------------------------------------------------------
initBuildsTable(); initBuildsTable();
initCurrentBuilds(); initCurrentBuilds();
initSystemTable(); initSystemTable();
initReloadButton();
setInterval(tickRunningDurations, 1000); setInterval(tickRunningDurations, 1000);
tickRunningDurations(); tickRunningDurations();
@@ -21,6 +21,8 @@
<nav class="view-toggle"> <nav class="view-toggle">
<span th:if="${view == 'latest'}">Latest</span> <span th:if="${view == 'latest'}">Latest</span>
<a th:unless="${view == 'latest'}" href="/">Latest</a> <a th:unless="${view == 'latest'}" href="/">Latest</a>
<span th:if="${view == 'branches'}">Branches</span>
<a th:unless="${view == 'branches'}" href="/branches">Branches</a>
<span th:if="${view == 'history'}">History</span> <span th:if="${view == 'history'}">History</span>
<a th:unless="${view == 'history'}" href="/history">History</a> <a th:unless="${view == 'history'}" href="/history">History</a>
<span th:if="${view == 'current'}">Current</span> <span th:if="${view == 'current'}">Current</span>
@@ -30,6 +32,7 @@
</nav> </nav>
<span class="view-row-actions"> <span class="view-row-actions">
<span id="live-indicator" class="status status-unknown" title="live-update state">static</span> <span id="live-indicator" class="status status-unknown" title="live-update state">static</span>
<button id="reload-button" class="reload-button" type="button" title="Reload view" aria-label="Reload view"></button>
</span> </span>
</div> </div>
@@ -101,6 +101,18 @@ class GitServiceTest : FunSpec() {
service.originBranches(fixture.work) shouldContainExactly listOf("main") service.originBranches(fixture.work) shouldContainExactly listOf("main")
} }
test("originBranchHeads maps every origin branch to its head commit, without HEAD") {
val fixture = Fixture()
fixture.pushNewSeedBranch("feature/x")
service.fetchOrigin(fixture.work)
val heads = service.originBranchHeads(fixture.work)
heads.keys shouldBe setOf("feature/x", "main")
heads["main"] shouldBe fixture.git(fixture.work, "rev-parse", "refs/remotes/origin/main").stdout.trim()
heads["feature/x"] shouldBe fixture.git(fixture.work, "rev-parse", "refs/remotes/origin/feature/x").stdout.trim()
}
test("fetchOrigin picks up new origin branches and prunes deleted ones") { test("fetchOrigin picks up new origin branches and prunes deleted ones") {
val fixture = Fixture() val fixture = Fixture()
fixture.pushNewSeedBranch("feature/x") fixture.pushNewSeedBranch("feature/x")
@@ -0,0 +1,64 @@
package de.hoennig.gittally.server
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.mockk.every
import io.mockk.mockk
import java.time.Duration
import java.time.Instant
class BranchListingTest : FunSpec() {
private val gitService = mockk<GitService>()
private val repository = mockk<BuildResultRepository>()
private val listing = BranchListing(gitService, repository)
private val mainResult =
BuildResult(
branch = "main",
commit = "0123456789abcdef0123456789abcdef01234567",
status = BuildStatus.SUCCESS,
startedAt = Instant.parse("2026-07-07T10:00:00Z"),
duration = Duration.ofSeconds(83),
artifactKey = "main-abc123-key",
)
init {
test("orders main/master first, then flat names, then hierarchical names") {
every { gitService.originBranchHeads(any()) } returns
mapOf(
"feature/x" to "aaa",
"zz-flat" to "bbb",
"main" to "ccc",
"aa/nested" to "ddd",
"develop" to "eee",
)
every { repository.latestFor(any()) } returns null
listing.branches().map { it.branch } shouldBe
listOf("main", "develop", "zz-flat", "aa/nested", "feature/x")
}
test("joins the latest build and marks never-built branches as unknown with the origin head") {
every { gitService.originBranchHeads(any()) } returns
mapOf("main" to "newer-head", "feature/x" to "fedcba98")
every { repository.latestFor("main") } returns mainResult
every { repository.latestFor("feature/x") } returns null
val branches = listing.branches()
branches[0].branch shouldBe "main"
branches[0].status shouldBe "success"
branches[0].commit shouldBe mainResult.commit
branches[0].artifactKey shouldBe "main-abc123-key"
branches[1].branch shouldBe "feature/x"
branches[1].status shouldBe "unknown"
branches[1].commit shouldBe "fedcba98"
branches[1].startedAt shouldBe null
branches[1].artifactKey shouldBe ""
}
}
}
@@ -7,6 +7,7 @@ import de.hoennig.gittally.build.BuildResult
import de.hoennig.gittally.build.BuildResultRepository import de.hoennig.gittally.build.BuildResultRepository
import de.hoennig.gittally.build.BuildStatus import de.hoennig.gittally.build.BuildStatus
import de.hoennig.gittally.build.RunningBuild import de.hoennig.gittally.build.RunningBuild
import de.hoennig.gittally.git.GitService
import io.kotest.core.spec.style.FunSpec import io.kotest.core.spec.style.FunSpec
import io.mockk.clearMocks import io.mockk.clearMocks
import io.mockk.every import io.mockk.every
@@ -43,6 +44,12 @@ class BuildsApiControllerTest : FunSpec() {
@MockkBean @MockkBean
lateinit var controlTokens: ControlTokenService lateinit var controlTokens: ControlTokenService
@MockkBean
lateinit var gitService: GitService
@MockkBean
lateinit var branchListing: BranchListing
private val startedAt = Instant.parse("2026-07-07T10:00:00Z") private val startedAt = Instant.parse("2026-07-07T10:00:00Z")
private val successResult = private val successResult =
@@ -67,7 +74,7 @@ class BuildsApiControllerTest : FunSpec() {
init { init {
beforeEach { beforeEach {
clearMocks(repository, buildExecutor, artifactStore, controlTokens) clearMocks(repository, buildExecutor, artifactStore, controlTokens, gitService, branchListing)
every { controlTokens.matches(any()) } answers { firstArg<String?>() == "secret" } every { controlTokens.matches(any()) } answers { firstArg<String?>() == "secret" }
} }
@@ -147,14 +154,50 @@ class BuildsApiControllerTest : FunSpec() {
verify { buildExecutor.startBuild("feature/topic", successResult.commit) } verify { buildExecutor.startBuild("feature/topic", successResult.commit) }
} }
test("restart of a branch without recorded builds answers 404") { test("restart of a never-built branch enqueues its origin head commit") {
val liveLogFile = tempDir.resolve("first-build.log")
every { repository.latestFor("fresh") } returns null
every { gitService.originHeadCommit("fresh", any()) } returns successResult.commit
every { buildExecutor.startBuild("fresh", successResult.commit) } returns
runningBuild(liveLogFile).copy(branch = "fresh")
mockMvc
.perform(post("/api/builds/restart").param("branch", "fresh").param("token", "secret"))
.andExpect(status().isAccepted)
.andExpect(jsonPath("$.status").value("pending"))
verify { buildExecutor.startBuild("fresh", successResult.commit) }
}
test("restart of a branch without recorded builds and without origin counterpart answers 404") {
every { repository.latestFor("gone") } returns null every { repository.latestFor("gone") } returns null
every { gitService.originHeadCommit("gone", any()) } returns null
mockMvc mockMvc
.perform(post("/api/builds/restart").param("branch", "gone").param("token", "secret")) .perform(post("/api/builds/restart").param("branch", "gone").param("token", "secret"))
.andExpect(status().isNotFound) .andExpect(status().isNotFound)
} }
test("branches answers the branch listing with unknown placeholders for never-built branches") {
every { branchListing.branches(any()) } returns
listOf(
BranchDto.from("main", "ignored-head", successResult),
BranchDto.from("feature/x", "fedcba9876543210fedcba9876543210fedcba98", null),
)
mockMvc
.perform(get("/api/branches"))
.andExpect(status().isOk)
.andExpect(jsonPath("$[0].branch").value("main"))
.andExpect(jsonPath("$[0].status").value("success"))
.andExpect(jsonPath("$[0].commit").value(successResult.commit))
.andExpect(jsonPath("$[1].branch").value("feature/x"))
.andExpect(jsonPath("$[1].status").value("unknown"))
.andExpect(jsonPath("$[1].commit").value("fedcba9876543210fedcba9876543210fedcba98"))
.andExpect(jsonPath("$[1].startedAt").doesNotExist())
.andExpect(jsonPath("$[1].artifactKey").value(""))
}
test("restart with a wrong token answers 403 and does not build") { test("restart with a wrong token answers 403 and does not build") {
mockMvc mockMvc
.perform( .perform(
@@ -55,6 +55,9 @@ class UiControllerTest : FunSpec() {
@MockkBean @MockkBean
lateinit var metricsCollector: SystemMetricsCollector lateinit var metricsCollector: SystemMetricsCollector
@MockkBean
lateinit var branchListing: BranchListing
private val startedAt = Instant.parse("2026-07-07T10:00:00Z") private val startedAt = Instant.parse("2026-07-07T10:00:00Z")
private val emptySystemMetrics = private val emptySystemMetrics =
@@ -85,7 +88,7 @@ class UiControllerTest : FunSpec() {
init { init {
beforeEach { beforeEach {
clearMocks(repository, buildExecutor, artifactStore, controlTokens, configLoader, metricsCollector) clearMocks(repository, buildExecutor, artifactStore, controlTokens, configLoader, metricsCollector, branchListing)
every { configLoader.load(any()) } returns every { configLoader.load(any()) } returns
GitTallyConfig( GitTallyConfig(
server = ServerConfig(impressumUrl = "https://example.org/imprint"), server = ServerConfig(impressumUrl = "https://example.org/imprint"),
@@ -102,6 +105,7 @@ class UiControllerTest : FunSpec() {
.andExpect(status().isOk) .andExpect(status().isOk)
.andExpect(content().string(containsString("No builds recorded yet."))) .andExpect(content().string(containsString("No builds recorded yet.")))
.andExpect(content().string(containsString("""data-api="/api/builds/latest""""))) .andExpect(content().string(containsString("""data-api="/api/builds/latest"""")))
.andExpect(content().string(containsString("""id="reload-button"""")))
} }
test("latest view renders rows with badge, Gitea links, artifact link, actions, and token") { test("latest view renders rows with badge, Gitea links, artifact link, actions, and token") {
@@ -122,6 +126,24 @@ class UiControllerTest : FunSpec() {
.andExpect(content().string(containsString("1:23"))) .andExpect(content().string(containsString("1:23")))
} }
test("branches view renders built and never-built branches with restart actions") {
every { branchListing.branches(any()) } returns
listOf(
BranchDto.from("main", "ignored-head", successResult),
BranchDto.from("feature/x", "fedcba9876543210fedcba9876543210fedcba98", null),
)
mockMvc
.perform(get("/branches"))
.andExpect(status().isOk)
.andExpect(content().string(containsString("status status-success")))
.andExpect(content().string(containsString("status status-unknown")))
.andExpect(content().string(containsString("feature/x")))
.andExpect(content().string(containsString("fedcba987654")))
.andExpect(content().string(containsString("""data-api="/api/branches"""")))
.andExpect(content().string(containsString("""data-action="restart"""")))
}
test("history view renders mixed history without restart actions") { test("history view renders mixed history without restart actions") {
every { repository.history() } returns every { repository.history() } returns
listOf( listOf(