diff --git a/src/main/kotlin/git/semver/plugin/scm/Commit.kt b/src/main/kotlin/git/semver/plugin/scm/Commit.kt index 9d1c938..e2e56e0 100644 --- a/src/main/kotlin/git/semver/plugin/scm/Commit.kt +++ b/src/main/kotlin/git/semver/plugin/scm/Commit.kt @@ -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, - 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 } diff --git a/src/main/kotlin/git/semver/plugin/scm/GitProvider.kt b/src/main/kotlin/git/semver/plugin/scm/GitProvider.kt index be9014e..b30e4cb 100644 --- a/src/main/kotlin/git/semver/plugin/scm/GitProvider.kt +++ b/src/main/kotlin/git/semver/plugin/scm/GitProvider.kt @@ -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 @@ -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 @@ -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 ) @@ -45,9 +49,8 @@ internal class GitProvider(internal val settings: SemverSettings) { } internal fun changeLog(it: Git): List { - 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( @@ -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) { @@ -134,26 +137,85 @@ internal class GitProvider(internal val settings: SemverSettings) { } private fun getTags(repository: Repository): Map> { - 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> = 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>): Set? { + 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>): List { + 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): List { + 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): Set { + 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?): 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) { diff --git a/src/main/kotlin/git/semver/plugin/semver/BaseSettings.kt b/src/main/kotlin/git/semver/plugin/semver/BaseSettings.kt index 7658c72..8dc70bd 100644 --- a/src/main/kotlin/git/semver/plugin/semver/BaseSettings.kt +++ b/src/main/kotlin/git/semver/plugin/semver/BaseSettings.kt @@ -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 ) } \ No newline at end of file diff --git a/src/main/kotlin/git/semver/plugin/semver/VersionFinder.kt b/src/main/kotlin/git/semver/plugin/semver/VersionFinder.kt index e8dbe0e..4502388 100644 --- a/src/main/kotlin/git/semver/plugin/semver/VersionFinder.kt +++ b/src/main/kotlin/git/semver/plugin/semver/VersionFinder.kt @@ -46,7 +46,7 @@ class VersionFinder(private val settings: SemverSettings, private val tags: Map< } private fun getChangeLog(commitData: List): MutableList = commitData.asReversed() - .filter { it.parents.size <= 1 } + .filter { it.parents.size <= 1 && !it.commit.ignored } .map { it.commit } .toMutableList() @@ -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 } diff --git a/src/test/kotlin/git/semver/plugin/scm/GitProviderPathFilterTest.kt b/src/test/kotlin/git/semver/plugin/scm/GitProviderPathFilterTest.kt new file mode 100644 index 0000000..b1d65e2 --- /dev/null +++ b/src/test/kotlin/git/semver/plugin/scm/GitProviderPathFilterTest.kt @@ -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") + } + } +}