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:
mhoennig
2026-09-02 08:58:04 +02:00
co-authored by Claude Fable 5.1
parent ab2a4133e8
commit b16c9c2e27
5 changed files with 214 additions and 11 deletions
@@ -10,7 +10,6 @@ import org.springframework.context.annotation.Profile
import org.springframework.stereotype.Component
import picocli.CommandLine
import picocli.CommandLine.IFactory
import java.nio.file.Paths
import kotlin.system.exitProcess
@SpringBootApplication
@@ -27,8 +26,6 @@ class CliRunner(
private var exitCode = 0
override fun run(vararg args: String) {
// before any command resolves a path under it, and once per process
StateDirMigration.migrateIfNeeded(Paths.get("."))
exitCode =
CommandLine(rootCommand, factory)
.setExecutionExceptionHandler { exception, commandLine, _ ->
@@ -2,15 +2,14 @@ package de.hoennig.werkator.repo
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import java.nio.file.Paths
@Configuration
class RepoConfiguration {
/**
* The single-repository case: the current working directory, which is how every
* CLI command and the server resolve their files. Only paths are computed here, so
* the bean is safe outside a git repository.
* The repository the unscoped code paths mean — the current working directory
* when it is served, see [RepoRegistry.current]. Without a registry only paths are
* computed here, so the bean is safe outside a git repository.
*/
@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
import de.hoennig.werkator.StateDirMigration
import de.hoennig.werkator.artifacts.FileArtifactStore
import de.hoennig.werkator.build.FileBuildResultRepository
import de.hoennig.werkator.config.ConfigLoader
import org.springframework.stereotype.Component
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
class RepoContexts(
private val configLoader: ConfigLoader,
@@ -14,13 +19,15 @@ class RepoContexts(
fun open(
workingDir: Path,
name: String = defaultName(workingDir),
): RepoContext =
RepoContext(
): RepoContext {
StateDirMigration.migrateIfNeeded(workingDir)
return RepoContext(
name = name,
workingDir = workingDir,
results = FileBuildResultRepository(workingDir.resolve(RESULTS_FILE)),
artifactStore = FileArtifactStore(configLoader, workingDir),
)
}
companion object {
/** 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()
}
}