Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 22 additions & 8 deletions buildSrc/src/main/kotlin/io/spine/gradle/ConfigTester.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down Expand Up @@ -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`.
*/
Expand Down Expand Up @@ -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()
Expand All @@ -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)
}

Expand Down Expand Up @@ -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)
50 changes: 19 additions & 31 deletions buildSrc/src/test/kotlin/io/spine/gradle/ClonedRepoSpec.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -47,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()

Expand All @@ -60,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()

Expand All @@ -80,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)
Expand All @@ -98,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()

Expand All @@ -111,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()

Expand All @@ -121,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"))

Expand All @@ -134,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"))

Expand All @@ -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
}
}
}
158 changes: 158 additions & 0 deletions buildSrc/src/test/kotlin/io/spine/gradle/GitRepositorySpec.kt
Original file line number Diff line number Diff line change
@@ -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.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,
// 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.createFile(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/<branch>` 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<GitAPIException> { 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 `<branch> -> <upstream>`.
*
* 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))
}
}
}
29 changes: 29 additions & 0 deletions buildSrc/src/test/kotlin/io/spine/gradle/PathExts.kt
Original file line number Diff line number Diff line change
@@ -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.createFile(relativePath: String, content: String): Path {
val file = resolve(relativePath)
file.parent.createDirectories()
file.writeText(content)
return file
}
Loading