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
3 changes: 2 additions & 1 deletion src/main/kotlin/git/semver/plugin/scm/Commit.kt
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@ package git.semver.plugin.scm
import java.util.Date

class Commit(override val text: String, override val sha: String, val commitTime: Int, val parents: Sequence<Commit>,
val authorName:String = "", val authorEmail:String = "", val authorWhen:Date = Date()) : IRefInfo {
val authorName:String = "", val authorEmail:String = "", val authorWhen:Date = Date(),
val ignored: Boolean = false) : IRefInfo {
override fun toString(): String = text
}

104 changes: 83 additions & 21 deletions src/main/kotlin/git/semver/plugin/scm/GitProvider.kt
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package git.semver.plugin.scm

import git.semver.plugin.semver.MutableSemVersion
import git.semver.plugin.semver.SemInfoVersion
import git.semver.plugin.semver.SemverSettings
import git.semver.plugin.semver.VersionFinder
Expand All @@ -10,6 +11,9 @@ import org.eclipse.jgit.lib.Repository
import org.eclipse.jgit.lib.RepositoryBuilder
import org.eclipse.jgit.revwalk.RevCommit
import org.eclipse.jgit.revwalk.RevWalk
import org.eclipse.jgit.treewalk.filter.AndTreeFilter
import org.eclipse.jgit.treewalk.filter.PathFilter
import org.eclipse.jgit.treewalk.filter.TreeFilter
import org.eclipse.jgit.util.FS
import org.slf4j.LoggerFactory
import java.io.File
Expand All @@ -30,9 +34,9 @@ internal class GitProvider(internal val settings: SemverSettings) {
}

internal fun semVersion(it: Git): SemInfoVersion {
val versionFinder = VersionFinder(settings, getTags(it.repository))
return versionFinder.getVersion(
getHeadCommit(it.repository),
val tags = getTags(it.repository)
return VersionFinder(settings, tags).getVersion(
getHeadCommit(it.repository, tags),
isClean(it),
settings.defaultPreRelease
)
Expand All @@ -45,9 +49,8 @@ internal class GitProvider(internal val settings: SemverSettings) {
}

internal fun changeLog(it: Git): List<Commit> {
val versionFinder = VersionFinder(settings, getTags(it.repository))
return versionFinder.getChangeLog(
getHeadCommit(it.repository))
val tags = getTags(it.repository)
return VersionFinder(settings, tags).getChangeLog(getHeadCommit(it.repository, tags))
}

internal fun createRelease(
Expand All @@ -65,9 +68,9 @@ internal class GitProvider(internal val settings: SemverSettings) {
) {
checkDirty(params.noDirtyCheck, isClean(it))

val versionFinder = VersionFinder(settings, getTags(it.repository))
val version = versionFinder.getReleaseVersion(
getHeadCommit(it.repository),
val tags = getTags(it.repository)
val version = VersionFinder(settings, tags).getReleaseVersion(
getHeadCommit(it.repository, tags),
params.preRelease?.trimStart('-')
)
if (version == null) {
Expand Down Expand Up @@ -134,26 +137,85 @@ internal class GitProvider(internal val settings: SemverSettings) {
}

private fun getTags(repository: Repository): Map<String, List<Tag>> {
return repository.refDatabase.getRefsByPrefix(REF_PREFIX).map {
Tag(it.name.removePrefix(REF_PREFIX), getObjectIdFromRef(repository, it).name)
}.groupBy { it.sha }
val tagPrefix = settings.releaseTagNameFormat
.takeIf { it.contains("%s") }
?.substringBefore("%s")
.orEmpty()
return repository.refDatabase.getRefsByPrefix(REF_PREFIX)
.map { Tag(it.name.removePrefix(REF_PREFIX), getObjectIdFromRef(repository, it).name) }
.filter { tagPrefix.isEmpty() || it.text.startsWith(tagPrefix) }
.groupBy { it.sha }
}

internal fun getHeadCommit(it: Repository): Commit {
val revWalk = RevWalk(it)
val head = it.resolve("HEAD") ?: return Commit("", "", 0, emptySequence())
val revCommit = revWalk.parseCommit(head)
revWalk.markStart(revCommit)
return getCommit(revCommit, revWalk)
internal fun getHeadCommit(repo: Repository, tags: Map<String, List<Tag>> = emptyMap()): Commit {
val revWalk = RevWalk(repo)
val head = repo.resolve("HEAD") ?: return Commit("", "", 0, emptySequence())
val headCommit = revWalk.parseCommit(head)

val relevantShas = computeRelevantShas(repo, head, tags)

revWalk.markStart(headCommit)
return getCommit(headCommit, revWalk, relevantShas)
}

private fun computeRelevantShas(repo: Repository, head: ObjectId, tags: Map<String, List<Tag>>): Set<String>? {
if (settings.pathFilter.isEmpty()) return null

val versionTagIds = getVersionTagObjectIds(tags)
val releaseMsgIds = findReleaseMessageCommits(repo, head, versionTagIds)
val boundaryIds = versionTagIds + releaseMsgIds

return filterCommitsByPath(repo, head, boundaryIds)
}

private fun getVersionTagObjectIds(tags: Map<String, List<Tag>>): List<ObjectId> {
return tags.entries
.filter { (_, tagList) -> tagList.any { MutableSemVersion.tryParse(it) != null } }
.mapNotNull { (sha, _) ->
try { ObjectId.fromString(sha) } catch (_: Exception) { null }
}
}

private fun findReleaseMessageCommits(repo: Repository, head: ObjectId, boundaryIds: List<ObjectId>): List<ObjectId> {
return RevWalk(repo).use { preWalk ->
preWalk.markStart(preWalk.parseCommit(head))
for (oid in boundaryIds) {
try { preWalk.markUninteresting(preWalk.parseCommit(oid)) } catch (_: Exception) {}
}
buildList {
for (rc in preWalk) {
val ref = object : IRefInfo { override val text = rc.fullMessage; override val sha = rc.name }
if (MutableSemVersion.isRelease(ref, settings) && MutableSemVersion.tryParse(ref) != null) {
add(rc.id)
}
}
}
}
}

private fun filterCommitsByPath(repo: Repository, head: ObjectId, boundaryIds: List<ObjectId>): Set<String> {
return RevWalk(repo).use { filterWalk ->
filterWalk.markStart(filterWalk.parseCommit(head))
for (oid in boundaryIds) {
try { filterWalk.markUninteresting(filterWalk.parseCommit(oid)) } catch (_: Exception) {}
}
filterWalk.treeFilter = AndTreeFilter.create(
PathFilter.create(settings.pathFilter),
TreeFilter.ANY_DIFF
)
filterWalk.map { it.name }.toHashSet()
}
}

private fun getCommit(commit: RevCommit, revWalk: RevWalk): Commit {
private fun getCommit(commit: RevCommit, revWalk: RevWalk, relevantShas: Set<String>?): Commit {
val ignored = relevantShas != null && commit.name !in relevantShas
return Commit(commit.fullMessage, commit.name, commit.commitTime, sequence {
for (parent in commit.parents) {
revWalk.parseHeaders(parent)
yield(getCommit(parent, revWalk))
yield(getCommit(parent, revWalk, relevantShas))
}
}, commit.authorIdent.name, commit.authorIdent.emailAddress, Date.from(commit.authorIdent.whenAsInstant))
}, commit.authorIdent.name, commit.authorIdent.emailAddress, Date.from(commit.authorIdent.whenAsInstant),
ignored)
}

internal fun checkDirty(noDirtyCheck: Boolean, isClean: Boolean) {
Expand Down
5 changes: 3 additions & 2 deletions src/main/kotlin/git/semver/plugin/semver/BaseSettings.kt
Original file line number Diff line number Diff line change
Expand Up @@ -16,12 +16,13 @@ abstract class BaseSettings(
var noReleaseAutoBump: Boolean = false,
var gitSigning: Boolean? = null, // null means use the jgit default
var metaSeparator: Char = '+',
var useTwoDigitVersion: Boolean = false // Enable 2-digit versioning (major.minor) instead of 3-digit (major.minor.patch)
var useTwoDigitVersion: Boolean = false, // Enable 2-digit versioning (major.minor) instead of 3-digit (major.minor.patch)
var pathFilter: String = ""
) : Serializable {
constructor(settings: BaseSettings) : this(
settings.defaultPreRelease, settings.releasePattern, settings.patchPattern, settings.minorPattern,
settings.majorPattern, settings.releaseCommitTextFormat, settings.releaseTagNameFormat,
settings.groupVersionIncrements, settings.noDirtyCheck, settings.noAutoBump, settings.noReleaseAutoBump,
settings.gitSigning, settings.metaSeparator, settings.useTwoDigitVersion
settings.gitSigning, settings.metaSeparator, settings.useTwoDigitVersion, settings.pathFilter
)
}
6 changes: 4 additions & 2 deletions src/main/kotlin/git/semver/plugin/semver/VersionFinder.kt
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ class VersionFinder(private val settings: SemverSettings, private val tags: Map<
}

private fun getChangeLog(commitData: List<CommitData>): MutableList<Commit> = commitData.asReversed()
.filter { it.parents.size <= 1 }
.filter { it.parents.size <= 1 && !it.commit.ignored }
.map { it.commit }
.toMutableList()

Expand All @@ -68,7 +68,9 @@ class VersionFinder(private val settings: SemverSettings, private val tags: Map<
.mapNotNull { result.visitedCommits.remove(it) }
.toList()
val maxVersionFromParents = getCombinedParentVersion(parentSemVersions)
maxVersionFromParents.updateFromCommit(commitData.commit, settings, preReleaseVersion)
if (!commitData.commit.ignored) {
maxVersionFromParents.updateFromCommit(commitData.commit, settings, preReleaseVersion)
}
result.visitedCommits[commitData.commit.sha] = maxVersionFromParents
lastFoundVersion = maxVersionFromParents
}
Expand Down
171 changes: 171 additions & 0 deletions src/test/kotlin/git/semver/plugin/scm/GitProviderPathFilterTest.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
package git.semver.plugin.scm

import git.semver.plugin.semver.SemverSettings
import org.assertj.core.api.Assertions.assertThat
import org.eclipse.jgit.api.Git
import org.junit.jupiter.api.io.TempDir
import java.io.File
import java.nio.file.Files
import java.nio.file.Path
import kotlin.test.Test

class GitProviderPathFilterTest {
companion object {
@TempDir
lateinit var tempDir: Path
}

private fun getGitDir(name: String): File {
val path = tempDir.resolve(name)
Files.createDirectories(path)
return path.toFile()
}

private fun commitFile(git: Git, relPath: String, message: String) {
val file = File(git.repository.workTree, relPath)
file.parentFile?.mkdirs()
file.writeText(message)
git.add().addFilepattern(relPath).call()
git.commit().setMessage(message).call()
}

@Test
fun `pathFilter empty behaves identically to no filter`() {
val gitDir = getGitDir("pathFilterEmpty")
val provider = GitProvider(SemverSettings().apply { pathFilter = "" })

Git.init().setDirectory(gitDir).call().use { git ->
commitFile(git, "README.md", "Initial commit")
commitFile(git, "lib/A.kt", "release: 1.0.0")
commitFile(git, "lib/B.kt", "feat: add B")

assertThat(provider.semVersion(git).toVersionString()).isEqualTo("1.1.0-SNAPSHOT")
}
}

@Test
fun `commits outside pathFilter dont bump version`() {
val gitDir = getGitDir("pathFilterOutside")
val provider = GitProvider(SemverSettings().apply { pathFilter = "lib" })

Git.init().setDirectory(gitDir).call().use { git ->
commitFile(git, "README.md", "Initial commit")
commitFile(git, "lib/A.kt", "release: 1.0.0")
commitFile(git, "services/S.kt", "feat: add service")

// feat in services/ only — should not bump version
assertThat(provider.semVersion(git).toVersionString()).isEqualTo("1.0.0")
}
}

@Test
fun `commits inside pathFilter do bump version`() {
val gitDir = getGitDir("pathFilterInside")
val provider = GitProvider(SemverSettings().apply { pathFilter = "lib" })

Git.init().setDirectory(gitDir).call().use { git ->
commitFile(git, "README.md", "Initial commit")
commitFile(git, "lib/A.kt", "release: 1.0.0")
commitFile(git, "lib/B.kt", "feat: add feature in lib")

assertThat(provider.semVersion(git).toVersionString()).isEqualTo("1.1.0-SNAPSHOT")
}
}

@Test
fun `release commits outside pathFilter are still found as base version`() {
val gitDir = getGitDir("pathFilterReleaseOutside")
val provider = GitProvider(SemverSettings().apply { pathFilter = "lib" })

Git.init().setDirectory(gitDir).call().use { git ->
// release commit touches README only — outside lib/ — but still acts as version base
commitFile(git, "README.md", "release: 1.0.0")
commitFile(git, "lib/A.kt", "feat: add feature in lib")

assertThat(provider.semVersion(git).toVersionString()).isEqualTo("1.1.0-SNAPSHOT")
}
}

@Test
fun `mixed commits touching both pathFilter and other dirs are included`() {
val gitDir = getGitDir("pathFilterMixed")
val provider = GitProvider(SemverSettings().apply { pathFilter = "lib" })

Git.init().setDirectory(gitDir).call().use { git ->
commitFile(git, "README.md", "Initial commit")
commitFile(git, "lib/A.kt", "release: 1.0.0")

// Single commit that touches both lib/ and services/
File(git.repository.workTree, "lib/C.kt").writeText("mixed")
File(git.repository.workTree, "services").mkdirs()
File(git.repository.workTree, "services/T.kt").writeText("mixed")
git.add().addFilepattern("lib/C.kt").addFilepattern("services/T.kt").call()
git.commit().setMessage("feat: mixed commit touching lib and services").call()

assertThat(provider.semVersion(git).toVersionString()).isEqualTo("1.1.0-SNAPSHOT")
}
}

@Test
fun `releaseTagNameFormat prefix filters which tags are tracked`() {
val gitDir = getGitDir("tagFormatFilter1")
// lib subproject: only track lib-v* tags
val provider = GitProvider(SemverSettings().apply {
releaseTagNameFormat = "lib-v%s"
})

Git.init().setDirectory(gitDir).call().use { git ->
commitFile(git, "README.md", "Initial commit")

// lib subproject release (older)
commitFile(git, "lib/A.kt", "lib commit")
git.tag().setName("lib-v1.0.0").call()

// services subproject release (newer — sits between HEAD and lib-v1.0.0)
// Without tag filtering this would be picked up as the base version (2.0.0)
commitFile(git, "services/S.kt", "services commit")
git.tag().setName("services-v2.0.0").call()

// new lib feature after both tags
commitFile(git, "lib/B.kt", "feat: add B")

val version = provider.semVersion(git)
// Must be based on lib-v1.0.0 (1.x.x), NOT services-v2.0.0 (2.x.x)
assertThat(version.toVersionString()).isEqualTo("1.1.0-SNAPSHOT")
}
}

@Test
fun `releaseTagNameFormat prefix empty tracks all tags (backward compat)`() {
val gitDir = getGitDir("tagFormatFilter2")
val provider = GitProvider(SemverSettings().apply {
releaseTagNameFormat = "%s" // default — no prefix filter
})

Git.init().setDirectory(gitDir).call().use { git ->
commitFile(git, "README.md", "Initial commit")
git.tag().setName("1.0.0").call()
commitFile(git, "lib/B.kt", "feat: add B")

assertThat(provider.semVersion(git).toVersionString()).isEqualTo("1.1.0-SNAPSHOT")
}
}

@Test
fun `ignored commits are excluded from changelog`() {
val gitDir = getGitDir("pathFilterChangelog")
val provider = GitProvider(SemverSettings().apply { pathFilter = "lib" })

Git.init().setDirectory(gitDir).call().use { git ->
commitFile(git, "README.md", "Initial commit")
commitFile(git, "lib/A.kt", "release: 1.0.0")
commitFile(git, "services/S.kt", "feat: add service outside lib")
commitFile(git, "lib/B.kt", "feat: add B in lib")

val messages = provider.changeLog(git).map { it.text }

assertThat(messages).contains("feat: add B in lib")
assertThat(messages).doesNotContain("feat: add service outside lib")
}
}
}
Loading