diff --git a/src/main/kotlin/de/hoennig/werkator/WerkatorApplication.kt b/src/main/kotlin/de/hoennig/werkator/WerkatorApplication.kt index 02d1462..99e5aa9 100644 --- a/src/main/kotlin/de/hoennig/werkator/WerkatorApplication.kt +++ b/src/main/kotlin/de/hoennig/werkator/WerkatorApplication.kt @@ -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, _ -> diff --git a/src/main/kotlin/de/hoennig/werkator/repo/RepoConfiguration.kt b/src/main/kotlin/de/hoennig/werkator/repo/RepoConfiguration.kt index acf23c7..eb75a23 100644 --- a/src/main/kotlin/de/hoennig/werkator/repo/RepoConfiguration.kt +++ b/src/main/kotlin/de/hoennig/werkator/repo/RepoConfiguration.kt @@ -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() } diff --git a/src/main/kotlin/de/hoennig/werkator/repo/RepoContexts.kt b/src/main/kotlin/de/hoennig/werkator/repo/RepoContexts.kt index 6304f7c..51524c2 100644 --- a/src/main/kotlin/de/hoennig/werkator/repo/RepoContexts.kt +++ b/src/main/kotlin/de/hoennig/werkator/repo/RepoContexts.kt @@ -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/`. */ diff --git a/src/main/kotlin/de/hoennig/werkator/repo/RepoRegistry.kt b/src/main/kotlin/de/hoennig/werkator/repo/RepoRegistry.kt new file mode 100644 index 0000000..0827fb3 --- /dev/null +++ b/src/main/kotlin/de/hoennig/werkator/repo/RepoRegistry.kt @@ -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 by lazy { open() } + + /** Every served repository, in registry order. */ + fun all(): List = 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 { + 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() + } +} diff --git a/src/test/kotlin/de/hoennig/werkator/repo/RepoRegistryTest.kt b/src/test/kotlin/de/hoennig/werkator/repo/RepoRegistryTest.kt new file mode 100644 index 0000000..38cd31d --- /dev/null +++ b/src/test/kotlin/de/hoennig/werkator/repo/RepoRegistryTest.kt @@ -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>() + 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 { 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 { 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) + } + } +}