From 851cb76fd03965b4fde77ce31873334312f7a4ab Mon Sep 17 00:00:00 2001 From: Juan Rojas Date: Fri, 24 Jul 2026 09:57:47 -0500 Subject: [PATCH] fix(pathFilter): don't let a descendant commit's release tag poison an earlier commit's version walk pathFilter's boundary computation (computeRelevantShas -> getVersionTagObjectIds) fed EVERY tag matching releaseTagNameFormat into RevWalk.markUninteresting(), regardless of whether that tag's commit was actually an ancestor of `head`. markUninteresting() propagates to all ancestors of the commit it's given. If a release tag sits on a commit that is a *descendant* of `head` (e.g. a build for an older commit is retried/delayed and, by the time it runs, a later commit has already been released), marking that descendant uninteresting also marks `head` itself uninteresting -- making every pending commit since the real last release look irrelevant. The version then silently fails to bump, with no error. Restrict tag boundaries to actual ancestors of `head` via RevWalk.isMergedInto() before using them to bound the pathFilter walk. --- .../git/semver/plugin/scm/GitProvider.kt | 22 ++++++- .../plugin/scm/GitProviderPathFilterTest.kt | 57 ++++++++++++++++++- 2 files changed, 74 insertions(+), 5 deletions(-) diff --git a/src/main/kotlin/git/semver/plugin/scm/GitProvider.kt b/src/main/kotlin/git/semver/plugin/scm/GitProvider.kt index b30e4cb..3bbf888 100644 --- a/src/main/kotlin/git/semver/plugin/scm/GitProvider.kt +++ b/src/main/kotlin/git/semver/plugin/scm/GitProvider.kt @@ -161,19 +161,35 @@ internal class GitProvider(internal val settings: SemverSettings) { private fun computeRelevantShas(repo: Repository, head: ObjectId, tags: Map>): Set? { if (settings.pathFilter.isEmpty()) return null - val versionTagIds = getVersionTagObjectIds(tags) + val versionTagIds = getVersionTagObjectIds(repo, head, 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 + private fun getVersionTagObjectIds(repo: Repository, head: ObjectId, tags: Map>): List { + val candidateIds = tags.entries .filter { (_, tagList) -> tagList.any { MutableSemVersion.tryParse(it) != null } } .mapNotNull { (sha, _) -> try { ObjectId.fromString(sha) } catch (_: Exception) { null } } + // Only tags reachable from `head` may bound the walk below. + return filterAncestorsOf(repo, head, candidateIds) + } + + private fun filterAncestorsOf(repo: Repository, head: ObjectId, candidateIds: List): List { + if (candidateIds.isEmpty()) return candidateIds + return RevWalk(repo).use { walk -> + val headCommit = walk.parseCommit(head) + candidateIds.filter { oid -> + try { + walk.isMergedInto(walk.parseCommit(oid), headCommit) + } catch (_: Exception) { + false + } + } + } } private fun findReleaseMessageCommits(repo: Repository, head: ObjectId, boundaryIds: List): List { diff --git a/src/test/kotlin/git/semver/plugin/scm/GitProviderPathFilterTest.kt b/src/test/kotlin/git/semver/plugin/scm/GitProviderPathFilterTest.kt index b1d65e2..081df9a 100644 --- a/src/test/kotlin/git/semver/plugin/scm/GitProviderPathFilterTest.kt +++ b/src/test/kotlin/git/semver/plugin/scm/GitProviderPathFilterTest.kt @@ -3,6 +3,7 @@ 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.eclipse.jgit.revwalk.RevCommit import org.junit.jupiter.api.io.TempDir import java.io.File import java.nio.file.Files @@ -21,12 +22,12 @@ class GitProviderPathFilterTest { return path.toFile() } - private fun commitFile(git: Git, relPath: String, message: String) { + private fun commitFile(git: Git, relPath: String, message: String): RevCommit { val file = File(git.repository.workTree, relPath) file.parentFile?.mkdirs() file.writeText(message) git.add().addFilepattern(relPath).call() - git.commit().setMessage(message).call() + return git.commit().setMessage(message).call() } @Test @@ -168,4 +169,56 @@ class GitProviderPathFilterTest { assertThat(messages).doesNotContain("feat: add service outside lib") } } + + @Test + fun `a release tag on a descendant commit does not hide pending changes on an earlier commit`() { + val gitDir = getGitDir("pathFilterDescendantTag") + val provider = GitProvider(SemverSettings().apply { + pathFilter = "lib" + releaseTagNameFormat = "v%s" + }) + + Git.init().setDirectory(gitDir).call().use { git -> + commitFile(git, "README.md", "Initial commit") + commitFile(git, "lib/A.kt", "release: 1.0.0") + git.tag().setName("v1.0.0").call() + + // Pending change, not yet released. + val pendingCommit = commitFile(git, "lib/B.kt", "fix: patch pending in lib") + + // A later commit lands and gets released first (descendant of pendingCommit). + commitFile(git, "lib/C.kt", "feat: a later feature also in lib") + git.tag().setName("v1.1.0").call() + + // Compute version as of the earlier, still-pending commit. + git.checkout().setName(pendingCommit.name).call() + + assertThat(provider.semVersion(git).toVersionString()).isEqualTo("1.0.1-SNAPSHOT") + } + } + + @Test + fun `changelog for an earlier commit is not hidden by a later release tag`() { + val gitDir = getGitDir("pathFilterDescendantTagChangelog") + val provider = GitProvider(SemverSettings().apply { + pathFilter = "lib" + releaseTagNameFormat = "v%s" + }) + + Git.init().setDirectory(gitDir).call().use { git -> + commitFile(git, "README.md", "Initial commit") + commitFile(git, "lib/A.kt", "release: 1.0.0") + git.tag().setName("v1.0.0").call() + + val pendingCommit = commitFile(git, "lib/B.kt", "fix: patch pending in lib") + + commitFile(git, "lib/C.kt", "feat: a later feature also in lib") + git.tag().setName("v1.1.0").call() + + git.checkout().setName(pendingCommit.name).call() + + val messages = provider.changeLog(git).map { it.text } + assertThat(messages).contains("fix: patch pending in lib") + } + } }