Step 22 C: the repository registry
RepoRegistry opens one RepoContext per entry of the home configuration, or the current directory without one. An entry that is no git repository or a name used twice aborts the start naming the home file; a repository whose config must not be read is skipped with an error and the others are served. The current-repo bean now comes from the registry (the cwd when served, else the first entry), and the pre-rename state-dir migration runs per opened repository. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5.1
parent
ab2a4133e8
commit
b16c9c2e27
@@ -10,7 +10,6 @@ import org.springframework.context.annotation.Profile
|
|||||||
import org.springframework.stereotype.Component
|
import org.springframework.stereotype.Component
|
||||||
import picocli.CommandLine
|
import picocli.CommandLine
|
||||||
import picocli.CommandLine.IFactory
|
import picocli.CommandLine.IFactory
|
||||||
import java.nio.file.Paths
|
|
||||||
import kotlin.system.exitProcess
|
import kotlin.system.exitProcess
|
||||||
|
|
||||||
@SpringBootApplication
|
@SpringBootApplication
|
||||||
@@ -27,8 +26,6 @@ class CliRunner(
|
|||||||
private var exitCode = 0
|
private var exitCode = 0
|
||||||
|
|
||||||
override fun run(vararg args: String) {
|
override fun run(vararg args: String) {
|
||||||
// before any command resolves a path under it, and once per process
|
|
||||||
StateDirMigration.migrateIfNeeded(Paths.get("."))
|
|
||||||
exitCode =
|
exitCode =
|
||||||
CommandLine(rootCommand, factory)
|
CommandLine(rootCommand, factory)
|
||||||
.setExecutionExceptionHandler { exception, commandLine, _ ->
|
.setExecutionExceptionHandler { exception, commandLine, _ ->
|
||||||
|
|||||||
@@ -2,15 +2,14 @@ package de.hoennig.werkator.repo
|
|||||||
|
|
||||||
import org.springframework.context.annotation.Bean
|
import org.springframework.context.annotation.Bean
|
||||||
import org.springframework.context.annotation.Configuration
|
import org.springframework.context.annotation.Configuration
|
||||||
import java.nio.file.Paths
|
|
||||||
|
|
||||||
@Configuration
|
@Configuration
|
||||||
class RepoConfiguration {
|
class RepoConfiguration {
|
||||||
/**
|
/**
|
||||||
* The single-repository case: the current working directory, which is how every
|
* The repository the unscoped code paths mean — the current working directory
|
||||||
* CLI command and the server resolve their files. Only paths are computed here, so
|
* when it is served, see [RepoRegistry.current]. Without a registry only paths are
|
||||||
* the bean is safe outside a git repository.
|
* computed here, so the bean is safe outside a git repository.
|
||||||
*/
|
*/
|
||||||
@Bean
|
@Bean
|
||||||
fun currentRepo(repoContexts: RepoContexts): RepoContext = repoContexts.open(Paths.get("."))
|
fun currentRepo(registry: RepoRegistry): RepoContext = registry.current()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,17 @@
|
|||||||
package de.hoennig.werkator.repo
|
package de.hoennig.werkator.repo
|
||||||
|
|
||||||
|
import de.hoennig.werkator.StateDirMigration
|
||||||
import de.hoennig.werkator.artifacts.FileArtifactStore
|
import de.hoennig.werkator.artifacts.FileArtifactStore
|
||||||
import de.hoennig.werkator.build.FileBuildResultRepository
|
import de.hoennig.werkator.build.FileBuildResultRepository
|
||||||
import de.hoennig.werkator.config.ConfigLoader
|
import de.hoennig.werkator.config.ConfigLoader
|
||||||
import org.springframework.stereotype.Component
|
import org.springframework.stereotype.Component
|
||||||
import java.nio.file.Path
|
import java.nio.file.Path
|
||||||
|
|
||||||
/** Opens a [RepoContext] over a repository directory; nothing is touched until the first build. */
|
/**
|
||||||
|
* Opens a [RepoContext] over a repository directory. Nothing is created until the first
|
||||||
|
* build; only a pre-rename state directory is moved to its current name on the way, per
|
||||||
|
* repository, before any path under it is resolved.
|
||||||
|
*/
|
||||||
@Component
|
@Component
|
||||||
class RepoContexts(
|
class RepoContexts(
|
||||||
private val configLoader: ConfigLoader,
|
private val configLoader: ConfigLoader,
|
||||||
@@ -14,13 +19,15 @@ class RepoContexts(
|
|||||||
fun open(
|
fun open(
|
||||||
workingDir: Path,
|
workingDir: Path,
|
||||||
name: String = defaultName(workingDir),
|
name: String = defaultName(workingDir),
|
||||||
): RepoContext =
|
): RepoContext {
|
||||||
RepoContext(
|
StateDirMigration.migrateIfNeeded(workingDir)
|
||||||
|
return RepoContext(
|
||||||
name = name,
|
name = name,
|
||||||
workingDir = workingDir,
|
workingDir = workingDir,
|
||||||
results = FileBuildResultRepository(workingDir.resolve(RESULTS_FILE)),
|
results = FileBuildResultRepository(workingDir.resolve(RESULTS_FILE)),
|
||||||
artifactStore = FileArtifactStore(configLoader, workingDir),
|
artifactStore = FileArtifactStore(configLoader, workingDir),
|
||||||
)
|
)
|
||||||
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
/** Results file relative to the repository, next to the machine config in `.git/werkator/`. */
|
/** Results file relative to the repository, next to the machine config in `.git/werkator/`. */
|
||||||
|
|||||||
@@ -0,0 +1,100 @@
|
|||||||
|
package de.hoennig.werkator.repo
|
||||||
|
|
||||||
|
import de.hoennig.werkator.config.ConfigException
|
||||||
|
import de.hoennig.werkator.config.ConfigLoader
|
||||||
|
import de.hoennig.werkator.config.RepositoryEntry
|
||||||
|
import org.slf4j.LoggerFactory
|
||||||
|
import org.springframework.stereotype.Component
|
||||||
|
import java.nio.file.Files
|
||||||
|
import java.nio.file.Path
|
||||||
|
import java.nio.file.Paths
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The repositories this instance serves (ADR 0009): one [RepoContext] per entry of the
|
||||||
|
* home configuration's `repositories`, or — without a home config or with an empty
|
||||||
|
* registry — the current working directory, exactly as before.
|
||||||
|
*
|
||||||
|
* Opened once, on first use, and loudly: an entry that is no git repository or a name
|
||||||
|
* used twice aborts the start with a message naming the home file, because an instance
|
||||||
|
* serving the wrong set is worse than one that does not come up. A repository whose
|
||||||
|
* configuration Werkator must not read (version or format violation) is the exception:
|
||||||
|
* it is skipped with an error, like a branch config violation fails only that branch —
|
||||||
|
* the other repositories keep building.
|
||||||
|
*/
|
||||||
|
@Component
|
||||||
|
class RepoRegistry(
|
||||||
|
private val configLoader: ConfigLoader,
|
||||||
|
private val repoContexts: RepoContexts,
|
||||||
|
) {
|
||||||
|
private val log = LoggerFactory.getLogger(RepoRegistry::class.java)
|
||||||
|
|
||||||
|
private val contexts: List<RepoContext> by lazy { open() }
|
||||||
|
|
||||||
|
/** Every served repository, in registry order. */
|
||||||
|
fun all(): List<RepoContext> = contexts
|
||||||
|
|
||||||
|
/** The repository registered under [name], or null. */
|
||||||
|
fun byName(name: String): RepoContext? = contexts.firstOrNull { it.name == name }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The repository a command without a selector means: the current working directory
|
||||||
|
* when it is served (so `werkator status` inside a repository behaves as today),
|
||||||
|
* otherwise the first registered one.
|
||||||
|
*/
|
||||||
|
fun current(): RepoContext {
|
||||||
|
val cwd = Paths.get(".").toAbsolutePath().normalize()
|
||||||
|
return contexts.firstOrNull { it.workingDir.toAbsolutePath().normalize() == cwd } ?: contexts.first()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun open(): List<RepoContext> {
|
||||||
|
val entries = configLoader.loadInstance()?.repositories.orEmpty()
|
||||||
|
if (entries.isEmpty()) {
|
||||||
|
return listOf(repoContexts.open(Paths.get(".")))
|
||||||
|
}
|
||||||
|
val home = configLoader.instanceFile()
|
||||||
|
val opened = entries.mapNotNull { openEntry(it, home) }
|
||||||
|
val duplicates = opened.groupBy { it.name }.filterValues { it.size > 1 }
|
||||||
|
if (duplicates.isNotEmpty()) {
|
||||||
|
val listed =
|
||||||
|
duplicates.entries.joinToString(
|
||||||
|
"; ",
|
||||||
|
) { (name, repos) -> "$name: ${repos.joinToString(", ") { it.workingDir.toString() }}" }
|
||||||
|
throw IllegalStateException("$home registers the same repository name more than once ($listed); set a distinct name per entry")
|
||||||
|
}
|
||||||
|
check(opened.isNotEmpty()) { "$home registers no readable repository" }
|
||||||
|
return opened
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun openEntry(
|
||||||
|
entry: RepositoryEntry,
|
||||||
|
home: Path,
|
||||||
|
): RepoContext? {
|
||||||
|
val dir = resolve(entry.path)
|
||||||
|
if (!Files.isDirectory(dir) || !Files.exists(dir.resolve(".git"))) {
|
||||||
|
throw IllegalStateException(
|
||||||
|
"$home registers ${entry.path.ifBlank { "an entry without a path" }}, which is not a git repository ($dir)",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
val name = entry.name.trim().ifEmpty { RepoContexts.defaultName(dir) }
|
||||||
|
try {
|
||||||
|
// the configuration is read here only to find out whether Werkator may read it at all
|
||||||
|
configLoader.load(dir)
|
||||||
|
} catch (e: ConfigException) {
|
||||||
|
log.error("not serving repository {} ({}): {}", name, dir, e.message)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return repoContexts.open(dir, name)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `~` expands to the home directory; a relative path is relative to the home directory, not the cwd. */
|
||||||
|
private fun resolve(path: String): Path {
|
||||||
|
val home = configLoader.homeDir
|
||||||
|
val expanded =
|
||||||
|
when {
|
||||||
|
path == "~" -> home
|
||||||
|
path.startsWith("~/") -> home.resolve(path.removePrefix("~/"))
|
||||||
|
else -> home.resolve(path)
|
||||||
|
}
|
||||||
|
return expanded.toAbsolutePath().normalize()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
package de.hoennig.werkator.repo
|
||||||
|
|
||||||
|
import de.hoennig.werkator.config.ConfigLoader
|
||||||
|
import io.kotest.assertions.throwables.shouldThrow
|
||||||
|
import io.kotest.core.spec.style.FunSpec
|
||||||
|
import io.kotest.matchers.shouldBe
|
||||||
|
import io.kotest.matchers.string.shouldContain
|
||||||
|
import io.mockk.every
|
||||||
|
import io.mockk.mockk
|
||||||
|
import org.springframework.beans.factory.ObjectProvider
|
||||||
|
import org.springframework.boot.info.BuildProperties
|
||||||
|
import java.nio.file.Files
|
||||||
|
import java.nio.file.Path
|
||||||
|
import java.nio.file.Paths
|
||||||
|
import java.util.Properties
|
||||||
|
|
||||||
|
class RepoRegistryTest : FunSpec() {
|
||||||
|
private fun loaderWithHome(
|
||||||
|
home: Path,
|
||||||
|
version: String = "1.0.0",
|
||||||
|
): ConfigLoader {
|
||||||
|
val provider = mockk<ObjectProvider<BuildProperties>>()
|
||||||
|
every { provider.getIfAvailable() } returns BuildProperties(Properties().apply { setProperty("version", version) })
|
||||||
|
return ConfigLoader(provider).apply { homeDir = home }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun gitRepo(
|
||||||
|
parent: Path,
|
||||||
|
name: String,
|
||||||
|
): Path = Files.createDirectories(parent.resolve(name)).also { Files.createDirectories(it.resolve(".git")) }
|
||||||
|
|
||||||
|
private fun registry(loader: ConfigLoader) = RepoRegistry(loader, RepoContexts(loader))
|
||||||
|
|
||||||
|
init {
|
||||||
|
test("without a home config the registry is the current directory alone") {
|
||||||
|
val home = Files.createTempDirectory("werkator-home")
|
||||||
|
val registry = registry(loaderWithHome(home))
|
||||||
|
|
||||||
|
registry.all().map { it.workingDir } shouldBe listOf(Paths.get("."))
|
||||||
|
registry.current().workingDir shouldBe Paths.get(".")
|
||||||
|
}
|
||||||
|
|
||||||
|
test("every registry entry becomes a context, named after its directory unless the entry says otherwise") {
|
||||||
|
val home = Files.createTempDirectory("werkator-home")
|
||||||
|
val one = gitRepo(home, "repos/werkator")
|
||||||
|
val two = gitRepo(home, "repos/werkbaum")
|
||||||
|
home.resolve(".werkator.yml").toFile().writeText(
|
||||||
|
"""
|
||||||
|
repositories:
|
||||||
|
- path: ~/repos/werkator
|
||||||
|
- path: $two
|
||||||
|
name: baum
|
||||||
|
""".trimIndent(),
|
||||||
|
)
|
||||||
|
|
||||||
|
val registry = registry(loaderWithHome(home))
|
||||||
|
|
||||||
|
registry.all().map { it.name to it.workingDir } shouldBe listOf("werkator" to one, "baum" to two)
|
||||||
|
registry.byName("baum")?.workingDir shouldBe two
|
||||||
|
registry.byName("nope") shouldBe null
|
||||||
|
// the cwd is not registered, so the first entry is the default
|
||||||
|
registry.current().name shouldBe "werkator"
|
||||||
|
}
|
||||||
|
|
||||||
|
test("an entry that is not a git repository aborts the start, naming the home file") {
|
||||||
|
val home = Files.createTempDirectory("werkator-home")
|
||||||
|
Files.createDirectories(home.resolve("not-a-repo"))
|
||||||
|
home.resolve(".werkator.yml").toFile().writeText("repositories:\n - path: ~/not-a-repo\n")
|
||||||
|
|
||||||
|
val error = shouldThrow<IllegalStateException> { registry(loaderWithHome(home)).all() }
|
||||||
|
|
||||||
|
error.message shouldContain home.resolve(".werkator.yml").toString()
|
||||||
|
error.message shouldContain "not a git repository"
|
||||||
|
}
|
||||||
|
|
||||||
|
test("two entries resolving to the same name abort the start") {
|
||||||
|
val home = Files.createTempDirectory("werkator-home")
|
||||||
|
gitRepo(home, "a/werkator")
|
||||||
|
gitRepo(home, "b/werkator")
|
||||||
|
home.resolve(".werkator.yml").toFile().writeText("repositories:\n - path: ~/a/werkator\n - path: ~/b/werkator\n")
|
||||||
|
|
||||||
|
val error = shouldThrow<IllegalStateException> { registry(loaderWithHome(home)).all() }
|
||||||
|
|
||||||
|
error.message shouldContain "werkator"
|
||||||
|
error.message shouldContain "distinct name"
|
||||||
|
}
|
||||||
|
|
||||||
|
test("a repository whose configuration must not be read is skipped, the others are served") {
|
||||||
|
val home = Files.createTempDirectory("werkator-home")
|
||||||
|
val fine = gitRepo(home, "fine")
|
||||||
|
val broken = gitRepo(home, "broken")
|
||||||
|
broken.resolve(".werkator.yml").toFile().writeText("werkator:\n version:\n since: \"9.9\"\n")
|
||||||
|
home.resolve(".werkator.yml").toFile().writeText("repositories:\n - path: ~/broken\n - path: ~/fine\n")
|
||||||
|
|
||||||
|
val registry = registry(loaderWithHome(home, version = "1.0.0"))
|
||||||
|
|
||||||
|
registry.all().map { it.workingDir } shouldBe listOf(fine)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user