From 349660e42c0665c44202cf538675cfbc8acfaaf7 Mon Sep 17 00:00:00 2001 From: alexander-yevsyukov Date: Mon, 19 Oct 2026 15:59:04 +0100 Subject: [PATCH 1/2] Make a non-default `Branch` of `ConfigTester` resolve MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The `Branch` override has never worked, so there was no way to run `buildDependants` against an unmerged branch of a consumer repository — which is exactly when one needs it. The feature that `config-tester.gradle.kts` documents in a comment, .addRepo(SpineRepos.coreJvm, Branch("grpc-concurrency-fixes")) died with org.eclipse.jgit.api.errors.RefNotFoundException: Ref cannot be resolved `GitRepository.checkout` cloned, and then checked the branch out as a second step. `Grgit.checkout` resolves a name against the clone's local ref database, which right after a clone holds only the default branch; every other branch is there solely as a remote-tracking ref. So the commented-out example was the one thing in that file that could not work, which is why it went unnoticed. Pass the ref to the clone instead. `CloneOp.refToCheckout` maps onto JGit's `CloneCommand.setBranch`, which resolves against the refs advertised by the remote, links local `HEAD` to the branch and adds its merge config. The branch thus lands as a proper local branch tracking its remote counterpart. This is preferred over adding `createBranch` and `startPoint` to the `checkout` call, which would need an "is this already a local ref?" special case to keep the default `Branch("master")` working. One code path now serves both. The added `close()` releases the JGit repository handle, which the discarded `Grgit` of the two-step code leaked on every checkout. A misspelled branch now fails faster and more clearly, with `TransportException: Remote branch 'x' not found in upstream origin`. It does not clone an empty working tree, which would have resurfaced the confusing "does not contain a Gradle build" failure that #805 removed. `GitRepositorySpec` covers the three cases without network access, by cloning a throwaway repository over its `file://` URI. It asserts the `HEAD` of the clone, along with the branch it tracks, because `Branch("origin/")` — the pre-fix workaround — holds the right files too, but as a detached `HEAD`. `Path.put()`, which both specs of the package need to lay out a folder, moves from the companion of `ClonedRepoSpec` to a top-level extension of its own, instead of being copied into the new spec. Co-Authored-By: Claude Opus 5 --- .../kotlin/io/spine/gradle/ConfigTester.kt | 30 +++- .../kotlin/io/spine/gradle/ClonedRepoSpec.kt | 12 -- .../io/spine/gradle/GitRepositorySpec.kt | 158 ++++++++++++++++++ .../kotlin/io/spine/gradle/PathExtensions.kt | 29 ++++ 4 files changed, 209 insertions(+), 20 deletions(-) create mode 100644 buildSrc/src/test/kotlin/io/spine/gradle/GitRepositorySpec.kt create mode 100644 buildSrc/src/test/kotlin/io/spine/gradle/PathExtensions.kt diff --git a/buildSrc/src/main/kotlin/io/spine/gradle/ConfigTester.kt b/buildSrc/src/main/kotlin/io/spine/gradle/ConfigTester.kt index fd906c5c..de954af0 100644 --- a/buildSrc/src/main/kotlin/io/spine/gradle/ConfigTester.kt +++ b/buildSrc/src/main/kotlin/io/spine/gradle/ConfigTester.kt @@ -103,7 +103,8 @@ class ConfigTester( } /** - * Adds a test. + * Adds a Git [repo] into the test build by its URI, to be checked out + * at the given [branch]. */ fun addRepo(repo: URI, branch: Branch): ConfigTester { repos.add(GitRepository(repo, branch)) @@ -187,7 +188,9 @@ class GitRepository( private val uri: URI, /** - * A branch to checkout. + * A branch to check out. + * + * May be any branch of the repository, not only the default one. * * By default, points to `master`. */ @@ -221,6 +224,9 @@ class GitRepository( * the `destinationFolder/foobar` folder. * * If the supplied folder does not exist, it is created. + * + * The [branch] is checked out as a local branch tracking its remote counterpart. + * A [branch] the remote does not advertise fails the checkout. */ fun checkout(destinationFolder: File): ClonedRepo { val preparedFolder = prepareCheckout(destinationFolder).toPath() @@ -229,16 +235,21 @@ class GitRepository( "to `${preparedFolder.toAbsolutePath()}`." ) + // The branch is checked out by the `clone` itself, rather than by a subsequent + // `checkout`. A fresh clone has only the default branch among its local refs, + // so `checkout` cannot resolve any other branch by its plain name, while + // `refToCheckout` is resolved against the refs advertised by the remote, + // where every branch of the repository is present. + // + // Only the checked-out files are of interest further on, so the Git repository + // the clone opens is closed right away. Grgit.clone( mapOf( "dir" to preparedFolder, - "uri" to uri - ) - ).checkout( - mapOf( - "branch" to branch.name + "uri" to uri, + "refToCheckout" to branch.name ) - ) + ).close() return ClonedRepo(this, preparedFolder) } @@ -488,5 +499,8 @@ object SpineRepos { /** * A name of a Git branch. + * + * The [name] is a plain branch name, such as `master` or `grpc-concurrency-fixes`. + * Neither the `refs/heads/` prefix nor the name of a remote is expected. */ data class Branch(val name: String) diff --git a/buildSrc/src/test/kotlin/io/spine/gradle/ClonedRepoSpec.kt b/buildSrc/src/test/kotlin/io/spine/gradle/ClonedRepoSpec.kt index bca5ed17..0d7c9ada 100644 --- a/buildSrc/src/test/kotlin/io/spine/gradle/ClonedRepoSpec.kt +++ b/buildSrc/src/test/kotlin/io/spine/gradle/ClonedRepoSpec.kt @@ -17,11 +17,9 @@ package io.spine.gradle import io.kotest.matchers.shouldBe import java.net.URI import java.nio.file.Path -import kotlin.io.path.createDirectories import kotlin.io.path.createSymbolicLinkPointingTo import kotlin.io.path.exists import kotlin.io.path.readText -import kotlin.io.path.writeText import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.DisplayName import org.junit.jupiter.api.Test @@ -156,15 +154,5 @@ internal class ClonedRepoSpec { const val MODULE_SCRIPT = "src/main/kotlin/module.gradle.kts" const val TEMPLATE_MODULE_SCRIPT = "// A template to be defined by a project." const val OWN_MODULE_SCRIPT = "plugins { `java-library` }" - - /** - * Creates a file under this folder, along with the folders leading to it. - */ - fun Path.put(relativePath: String, content: String): Path { - val file = resolve(relativePath) - file.parent.createDirectories() - file.writeText(content) - return file - } } } diff --git a/buildSrc/src/test/kotlin/io/spine/gradle/GitRepositorySpec.kt b/buildSrc/src/test/kotlin/io/spine/gradle/GitRepositorySpec.kt new file mode 100644 index 00000000..ec0fe435 --- /dev/null +++ b/buildSrc/src/test/kotlin/io/spine/gradle/GitRepositorySpec.kt @@ -0,0 +1,158 @@ +/* + * Copyright 2026 CodeMatters, Lda. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file + * except in compliance with the License. You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, + * either express or implied. See the License for the specific language governing permissions + * and limitations under the License. + */ + +package io.spine.gradle + +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.matchers.paths.shouldNotExist +import io.kotest.matchers.shouldBe +import io.kotest.matchers.string.shouldContain +import java.io.File +import java.nio.file.Path +import kotlin.io.path.createDirectories +import kotlin.io.path.readText +import org.ajoberstar.grgit.Grgit +import org.eclipse.jgit.api.errors.GitAPIException +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.io.TempDir + +@DisplayName("`GitRepository` should") +internal class GitRepositorySpec { + + @TempDir + lateinit var tempDir: Path + + /** The repository to be cloned, playing the role of a remote. */ + private lateinit var origin: Path + + /** The folder the repository is checked out into. */ + private lateinit var destination: File + + /** The root of the checked-out repository. */ + private val clone: Path + get() = destination.toPath().resolve(REPO_NAME) + + /** + * Creates the repository to be cloned, with two branches. + * + * Both branches carry [SHARED_FILE]. Only [FEATURE] carries [FEATURE_FILE], + * which thus tells the branches apart in the clone. + * + * The created repository is left at [MASTER], so that this is the branch + * a clone checks out by default. + */ + @BeforeEach + fun createOrigin() { + origin = tempDir.resolve(REPO_NAME).createDirectories() + destination = tempDir.resolve("checkout").toFile() + Grgit.init(mapOf("dir" to origin)).use { git -> + origin.put(SHARED_FILE, SHARED_TEXT) + git.commitAll("Add the file shared by all branches.") + + // Git does not mandate the name of the branch a repository starts with, + // and JGit honors `init.defaultBranch`, so make sure `master` exists. + if (git.branch.current().name != MASTER) { + git.checkout(mapOf("branch" to MASTER, "createBranch" to true)) + } + + git.checkout(mapOf("branch" to FEATURE, "createBranch" to true)) + origin.put(FEATURE_FILE, FEATURE_TEXT) + git.commitAll("Add the file of the feature branch.") + + git.checkout(mapOf("branch" to MASTER)) + } + } + + @Test + fun `check out the default branch`() { + GitRepository(origin.toUri()).checkout(destination) + + clone.resolve(SHARED_FILE).readText() shouldBe SHARED_TEXT + clone.resolve(FEATURE_FILE).shouldNotExist() + headOfClone() shouldBe "$MASTER -> origin/$MASTER" + } + + /** + * Verifies that a branch is resolved by its plain name and is checked out + * as a local branch rather than as a detached `HEAD`. + * + * A fresh clone has only the default branch among its local refs, so the branch + * has to be resolved against the refs advertised by the remote. `HEAD` detached + * at `origin/` would hold the right files just as well, which is why + * the `HEAD` of the clone is asserted too. + */ + @Test + fun `check out a branch other than the default one`() { + GitRepository(origin.toUri(), Branch(FEATURE)).checkout(destination) + + clone.resolve(FEATURE_FILE).readText() shouldBe FEATURE_TEXT + headOfClone() shouldBe "$FEATURE -> origin/$FEATURE" + } + + /** + * Verifies that a missing branch fails the checkout right away. + * + * Were the clone to succeed and leave an empty working tree behind, the Gradle + * build of the repository would fail later on with a far less obvious message. + */ + @Test + fun `fail to check out a branch that does not exist`() { + val repo = GitRepository(origin.toUri(), Branch(MISSING)) + + val error = shouldThrow { repo.checkout(destination) } + + // `GitAPIException` roots the whole JGit error hierarchy, so the branch + // has to be named for this to pin the branch resolution rather than, + // say, an unreadable `origin`. + error.message shouldContain MISSING + } + + /** + * Returns the current branch of the clone, along with the branch it tracks, + * as ` -> `. + * + * A detached `HEAD` reads as `HEAD -> null`. + */ + private fun headOfClone(): String = + Grgit.open(mapOf("dir" to clone)).use { + val head = it.branch.current() + "${head.name} -> ${head.trackingBranch?.name}" + } + + private companion object { + + /** The name of the folder the repository is cloned into. */ + const val REPO_NAME = "acme-lib" + + const val MASTER = "master" + const val FEATURE = "grpc-concurrency-fixes" + const val MISSING = "no-such-branch" + + const val SHARED_FILE = "README.md" + const val SHARED_TEXT = "The file shared by all branches." + + const val FEATURE_FILE = "src/main/kotlin/Concurrency.kt" + const val FEATURE_TEXT = "object Concurrency" + + /** + * Commits all the files of this repository, including the new ones. + */ + fun Grgit.commitAll(message: String) { + add(mapOf("patterns" to setOf("."))) + commit(mapOf("message" to message)) + } + } +} diff --git a/buildSrc/src/test/kotlin/io/spine/gradle/PathExtensions.kt b/buildSrc/src/test/kotlin/io/spine/gradle/PathExtensions.kt new file mode 100644 index 00000000..b7429e26 --- /dev/null +++ b/buildSrc/src/test/kotlin/io/spine/gradle/PathExtensions.kt @@ -0,0 +1,29 @@ +/* + * Copyright 2026 CodeMatters, Lda. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file + * except in compliance with the License. You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, + * either express or implied. See the License for the specific language governing permissions + * and limitations under the License. + */ + +package io.spine.gradle + +import java.nio.file.Path +import kotlin.io.path.createDirectories +import kotlin.io.path.writeText + +/** + * Creates a file under this folder, along with the folders leading to it. + */ +internal fun Path.put(relativePath: String, content: String): Path { + val file = resolve(relativePath) + file.parent.createDirectories() + file.writeText(content) + return file +} From 587105ee6cd10b2b118ecace7136d2ed0127c599 Mon Sep 17 00:00:00 2001 From: alexander-yevsyukov Date: Mon, 19 Oct 2026 16:19:08 +0100 Subject: [PATCH 2/2] Rename the shared `Path` extension of the specs `put()` says where the file goes but not what happens there, so the extension both specs of the package use to lay out a folder becomes `createFile()`, and its file becomes `PathExts.kt`. The name is an overload of `kotlin.io.path.createFile()` rather than a clash: that one takes file attributes, this one a relative path and the content. Neither spec imports the standard-library extension. Co-Authored-By: Claude Opus 5 --- .../kotlin/io/spine/gradle/ClonedRepoSpec.kt | 38 +++++++++---------- .../io/spine/gradle/GitRepositorySpec.kt | 4 +- .../gradle/{PathExtensions.kt => PathExts.kt} | 2 +- 3 files changed, 22 insertions(+), 22 deletions(-) rename buildSrc/src/test/kotlin/io/spine/gradle/{PathExtensions.kt => PathExts.kt} (92%) diff --git a/buildSrc/src/test/kotlin/io/spine/gradle/ClonedRepoSpec.kt b/buildSrc/src/test/kotlin/io/spine/gradle/ClonedRepoSpec.kt index 0d7c9ada..c9f102c5 100644 --- a/buildSrc/src/test/kotlin/io/spine/gradle/ClonedRepoSpec.kt +++ b/buildSrc/src/test/kotlin/io/spine/gradle/ClonedRepoSpec.kt @@ -45,8 +45,8 @@ internal class ClonedRepoSpec { @Test fun `copy the files, and not only the folders`() { - source.put("quality/detekt-config.yml", "The quality settings.") - source.put("src/main/kotlin/io/spine/dependency/build/Ksp.kt", "object Ksp") + source.createFile("quality/detekt-config.yml", "The quality settings.") + source.createFile("src/main/kotlin/io/spine/dependency/build/Ksp.kt", "object Ksp") replaceBuildSrc() @@ -58,12 +58,12 @@ internal class ClonedRepoSpec { @Test fun `skip the build output and the Git metadata`() { - source.put(BUILD_SCRIPT, "The build script.") - source.put("src/main/kotlin/Deps.kt", "object Deps") - source.put("build/classes/Deps.class", "The compiled code.") - source.put(".gradle/file-system.probe", "The probe.") - source.put(".kotlin/sessions/session.salive", "The session.") - source.put(".git/HEAD", "ref: refs/heads/master") + source.createFile(BUILD_SCRIPT, "The build script.") + source.createFile("src/main/kotlin/Deps.kt", "object Deps") + source.createFile("build/classes/Deps.class", "The compiled code.") + source.createFile(".gradle/file-system.probe", "The probe.") + source.createFile(".kotlin/sessions/session.salive", "The session.") + source.createFile(".git/HEAD", "ref: refs/heads/master") replaceBuildSrc() @@ -78,11 +78,11 @@ internal class ClonedRepoSpec { @Test fun `skip the build output of a nested Gradle project`() { val config = tempDir.resolve("config") - config.put(BUILD_SCRIPT, "The build script.") - config.put("$BUILD_SRC/settings.gradle.kts", "The settings of `buildSrc`.") - config.put("$BUILD_SRC/build/classes/Deps.class", "The compiled code.") - config.put("$BUILD_SRC/.gradle/file-system.probe", "The probe.") - config.put("$BUILD_SRC/src/main/kotlin/io/spine/dependency/build/Ksp.kt", "object Ksp") + config.createFile(BUILD_SCRIPT, "The build script.") + config.createFile("$BUILD_SRC/settings.gradle.kts", "The settings of `buildSrc`.") + config.createFile("$BUILD_SRC/build/classes/Deps.class", "The compiled code.") + config.createFile("$BUILD_SRC/.gradle/file-system.probe", "The probe.") + config.createFile("$BUILD_SRC/src/main/kotlin/io/spine/dependency/build/Ksp.kt", "object Ksp") val repo = GitRepository(URI("https://github.com/SpineEventEngine/base")) ClonedRepo(repo, location).replaceConfig(config, null) @@ -96,8 +96,8 @@ internal class ClonedRepoSpec { @Test fun `preserve the 'module gradle kts' of the repository under test`() { - source.put(MODULE_SCRIPT, TEMPLATE_MODULE_SCRIPT) - location.resolve(BUILD_SRC).put(MODULE_SCRIPT, OWN_MODULE_SCRIPT) + source.createFile(MODULE_SCRIPT, TEMPLATE_MODULE_SCRIPT) + location.resolve(BUILD_SRC).createFile(MODULE_SCRIPT, OWN_MODULE_SCRIPT) replaceBuildSrc() @@ -109,7 +109,7 @@ internal class ClonedRepoSpec { @Test fun `take the template 'module gradle kts' when the repository has none`() { - source.put(MODULE_SCRIPT, TEMPLATE_MODULE_SCRIPT) + source.createFile(MODULE_SCRIPT, TEMPLATE_MODULE_SCRIPT) replaceBuildSrc() @@ -119,7 +119,7 @@ internal class ClonedRepoSpec { @Test fun `skip a link that does not resolve`() { - source.put("src/main/kotlin/Deps.kt", "object Deps") + source.createFile("src/main/kotlin/Deps.kt", "object Deps") val link = source.resolve("guidelines") link.createSymbolicLinkPointingTo(source.resolve("shared/guidelines")) @@ -132,8 +132,8 @@ internal class ClonedRepoSpec { @Test fun `skip the ignored folder`() { - source.put("src/main/kotlin/Deps.kt", "object Deps") - source.put("tmp/base/README.md", "The checked out sources.") + source.createFile("src/main/kotlin/Deps.kt", "object Deps") + source.createFile("tmp/base/README.md", "The checked out sources.") replaceBuildSrc(ignoredFolder = source.resolve("tmp")) diff --git a/buildSrc/src/test/kotlin/io/spine/gradle/GitRepositorySpec.kt b/buildSrc/src/test/kotlin/io/spine/gradle/GitRepositorySpec.kt index ec0fe435..791a9ca7 100644 --- a/buildSrc/src/test/kotlin/io/spine/gradle/GitRepositorySpec.kt +++ b/buildSrc/src/test/kotlin/io/spine/gradle/GitRepositorySpec.kt @@ -59,7 +59,7 @@ internal class GitRepositorySpec { origin = tempDir.resolve(REPO_NAME).createDirectories() destination = tempDir.resolve("checkout").toFile() Grgit.init(mapOf("dir" to origin)).use { git -> - origin.put(SHARED_FILE, SHARED_TEXT) + origin.createFile(SHARED_FILE, SHARED_TEXT) git.commitAll("Add the file shared by all branches.") // Git does not mandate the name of the branch a repository starts with, @@ -69,7 +69,7 @@ internal class GitRepositorySpec { } git.checkout(mapOf("branch" to FEATURE, "createBranch" to true)) - origin.put(FEATURE_FILE, FEATURE_TEXT) + origin.createFile(FEATURE_FILE, FEATURE_TEXT) git.commitAll("Add the file of the feature branch.") git.checkout(mapOf("branch" to MASTER)) diff --git a/buildSrc/src/test/kotlin/io/spine/gradle/PathExtensions.kt b/buildSrc/src/test/kotlin/io/spine/gradle/PathExts.kt similarity index 92% rename from buildSrc/src/test/kotlin/io/spine/gradle/PathExtensions.kt rename to buildSrc/src/test/kotlin/io/spine/gradle/PathExts.kt index b7429e26..c0ae9479 100644 --- a/buildSrc/src/test/kotlin/io/spine/gradle/PathExtensions.kt +++ b/buildSrc/src/test/kotlin/io/spine/gradle/PathExts.kt @@ -21,7 +21,7 @@ import kotlin.io.path.writeText /** * Creates a file under this folder, along with the folders leading to it. */ -internal fun Path.put(relativePath: String, content: String): Path { +internal fun Path.createFile(relativePath: String, content: String): Path { val file = resolve(relativePath) file.parent.createDirectories() file.writeText(content)