diff --git a/.idea/modules.xml b/.idea/modules.xml
index 06c5870dadf1..b5bc5ff3740e 100644
--- a/.idea/modules.xml
+++ b/.idea/modules.xml
@@ -957,6 +957,7 @@
+
diff --git a/plugins/coverage-common/resources/intellij.platform.coverage.xml b/plugins/coverage-common/resources/intellij.platform.coverage.xml
index d8df14ae58e5..2757c9a7180f 100644
--- a/plugins/coverage-common/resources/intellij.platform.coverage.xml
+++ b/plugins/coverage-common/resources/intellij.platform.coverage.xml
@@ -9,6 +9,7 @@
+
diff --git a/plugins/coverage-common/resources/messages/CoverageBundle.properties b/plugins/coverage-common/resources/messages/CoverageBundle.properties
index 2367a0d7fc44..77355d0f0297 100644
--- a/plugins/coverage-common/resources/messages/CoverageBundle.properties
+++ b/plugins/coverage-common/resources/messages/CoverageBundle.properties
@@ -86,6 +86,7 @@ coverage.error.loading.report=Error loading coverage report
coverage.flatten.packages=Flatten packages
coverage.hide.fully.covered.elements=Hide Fully Covered {0}
coverage.show.only.modified.elements=Show Only {0} with Uncommitted Changes
+coverage.show.only.elements.in.feature.branch=Show Only Modified {0} (Compared to {1} Branch)
coverage.show.fully.covered.elements=Show fully covered {0}
coverage.show.unmodified.elements=Show {0} without uncommitted changes
coverage.view.filters.group=Filters
diff --git a/plugins/coverage-common/src/com/intellij/coverage/BaseCoverageAnnotator.java b/plugins/coverage-common/src/com/intellij/coverage/BaseCoverageAnnotator.java
index 8839c174e79e..1b69c4df24d8 100644
--- a/plugins/coverage-common/src/com/intellij/coverage/BaseCoverageAnnotator.java
+++ b/plugins/coverage-common/src/com/intellij/coverage/BaseCoverageAnnotator.java
@@ -39,7 +39,8 @@ public abstract class BaseCoverageAnnotator implements CoverageAnnotator {
ProgressManager.getInstance().run(new Task.Backgroundable(project, CoverageBundle.message("coverage.view.loading.data"), true) {
@Override
public void run(@NotNull ProgressIndicator indicator) {
- request.run();
+ myModifiedFilesFilter = ModifiedFilesFilter.create(project);
+ request.run();
}
@Override
@@ -64,9 +65,7 @@ public abstract class BaseCoverageAnnotator implements CoverageAnnotator {
@ApiStatus.Internal
@Override
- public synchronized @Nullable ModifiedFilesFilter getModifiedFilesFilter() {
- if (myModifiedFilesFilter != null) return myModifiedFilesFilter;
- myModifiedFilesFilter = ModifiedFilesFilter.create(myProject);
+ public @Nullable ModifiedFilesFilter getModifiedFilesFilter() {
return myModifiedFilesFilter;
}
diff --git a/plugins/coverage-common/src/com/intellij/coverage/filters/ModifiedFilesFilter.kt b/plugins/coverage-common/src/com/intellij/coverage/filters/ModifiedFilesFilter.kt
index 1a3654fd491b..91de6f6e64bf 100644
--- a/plugins/coverage-common/src/com/intellij/coverage/filters/ModifiedFilesFilter.kt
+++ b/plugins/coverage-common/src/com/intellij/coverage/filters/ModifiedFilesFilter.kt
@@ -6,9 +6,10 @@ import com.intellij.openapi.vcs.FileStatus
import com.intellij.openapi.vcs.FileStatusManager
import com.intellij.openapi.vfs.VirtualFile
import org.jetbrains.annotations.ApiStatus
+import org.jetbrains.annotations.Nls
@ApiStatus.Internal
-class ModifiedFilesFilter(private val project: Project) {
+open class ModifiedFilesFilter(private val project: Project) {
@Volatile
var hasFilteredFiles: Boolean = false
@@ -19,7 +20,7 @@ class ModifiedFilesFilter(private val project: Project) {
}
fun isFileModified(file: VirtualFile): Boolean {
- val isModified = isModifiedLocally(file)
+ val isModified = isModifiedLocally(file) || isInModifiedScope(file)
return isModified.also {
if (!isModified && !hasFilteredFiles) {
hasFilteredFiles = true
@@ -27,6 +28,10 @@ class ModifiedFilesFilter(private val project: Project) {
}
}
+ open fun isInModifiedScope(file: VirtualFile) = false
+
+ open fun getBranchName(): @Nls String? = null
+
private fun isModifiedLocally(file: VirtualFile): Boolean {
val status = FileStatusManager.getInstance(project).getStatus(file)
val isCurrentlyChanged = status === FileStatus.MODIFIED || status === FileStatus.ADDED || status === FileStatus.UNKNOWN
@@ -36,6 +41,10 @@ class ModifiedFilesFilter(private val project: Project) {
companion object {
@JvmStatic
fun create(project: Project): ModifiedFilesFilter {
+ val filter = ModifiedFilesFilterFactory.EP_NAME.computeSafeIfAny {
+ it.createFilter(project)
+ }
+ if (filter != null) return filter
return ModifiedFilesFilter(project)
}
}
diff --git a/plugins/coverage-common/src/com/intellij/coverage/filters/ModifiedFilesFilterFactory.kt b/plugins/coverage-common/src/com/intellij/coverage/filters/ModifiedFilesFilterFactory.kt
new file mode 100644
index 000000000000..e8c6da980b4c
--- /dev/null
+++ b/plugins/coverage-common/src/com/intellij/coverage/filters/ModifiedFilesFilterFactory.kt
@@ -0,0 +1,16 @@
+// Copyright 2000-2024 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
+package com.intellij.coverage.filters
+
+import com.intellij.openapi.extensions.ExtensionPointName
+import com.intellij.openapi.project.Project
+import org.jetbrains.annotations.ApiStatus
+
+@ApiStatus.Internal
+interface ModifiedFilesFilterFactory {
+ fun createFilter(project: Project): ModifiedFilesFilter?
+
+ companion object {
+ @JvmStatic
+ val EP_NAME: ExtensionPointName = ExtensionPointName.create("com.intellij.coverageModifiedFilesFilterFactory")
+ }
+}
diff --git a/plugins/coverage-common/src/com/intellij/coverage/view/CoverageView.java b/plugins/coverage-common/src/com/intellij/coverage/view/CoverageView.java
index 27804afc6acc..f37a91819b43 100644
--- a/plugins/coverage-common/src/com/intellij/coverage/view/CoverageView.java
+++ b/plugins/coverage-common/src/com/intellij/coverage/view/CoverageView.java
@@ -26,6 +26,7 @@ import com.intellij.openapi.project.Project;
import com.intellij.openapi.ui.Messages;
import com.intellij.openapi.util.Comparing;
import com.intellij.openapi.util.Disposer;
+import com.intellij.openapi.util.NlsActions;
import com.intellij.openapi.util.Ref;
import com.intellij.openapi.vcs.FileStatusListener;
import com.intellij.openapi.vcs.FileStatusManager;
@@ -46,6 +47,7 @@ import com.intellij.util.ui.StatusText;
import com.intellij.util.ui.UIUtil;
import com.intellij.util.ui.components.BorderLayoutPanel;
import com.intellij.util.ui.tree.TreeUtil;
+import org.jetbrains.annotations.Nls;
import org.jetbrains.annotations.NonNls;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
@@ -64,6 +66,7 @@ import java.awt.event.MouseEvent;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
+import java.util.Objects;
public class CoverageView extends BorderLayoutPanel implements DataProvider, Disposable {
@NonNls private static final String ACTION_DRILL_DOWN = "DrillDown";
@@ -192,11 +195,15 @@ public class CoverageView extends BorderLayoutPanel implements DataProvider, Dis
}
private boolean hasVCSFilteredNodes() {
- CoverageAnnotator annotator = mySuitesBundle.getCoverageEngine().getCoverageAnnotator(myProject);
- ModifiedFilesFilter filter = annotator.getModifiedFilesFilter();
+ var filter = getModifiedFilesFilter();
return filter != null && filter.getHasFilteredFiles();
}
+ private @Nullable ModifiedFilesFilter getModifiedFilesFilter() {
+ CoverageAnnotator annotator = mySuitesBundle.getCoverageEngine().getCoverageAnnotator(myProject);
+ return annotator.getModifiedFilesFilter();
+ }
+
private void setUpShowRootNode(ActionToolbar actionToolbar) {
final var showFull = new Ref<>(false);
myModel.addTreeModelListener(new TreeModelListener() {
@@ -386,8 +393,8 @@ public class CoverageView extends BorderLayoutPanel implements DataProvider, Dis
boolean hasFilters = false;
final DefaultActionGroup filtersActionGroup = new DefaultActionGroup();
- if (ProjectLevelVcsManager.getInstance(myProject).hasActiveVcss()) {
- filtersActionGroup.add(new ShowOnlyModifiedAction());
+ if (ProjectLevelVcsManager.getInstance(myProject).hasActiveVcss() && getModifiedFilesFilter() != null) {
+ filtersActionGroup.add(new ShowOnlyModifiedAction(getModifiedActionName()));
hasFilters = true;
myHasVCSFilter = true;
}
@@ -574,8 +581,8 @@ public class CoverageView extends BorderLayoutPanel implements DataProvider, Dis
private final class ShowOnlyModifiedAction extends ToggleAction {
- private ShowOnlyModifiedAction() {
- super(CoverageBundle.messagePointer("coverage.show.only.modified.elements", myViewExtension.getElementsCapitalisedName()));
+ private ShowOnlyModifiedAction(@NlsActions.ActionText String name) {
+ super(name);
}
@Override
@@ -594,6 +601,17 @@ public class CoverageView extends BorderLayoutPanel implements DataProvider, Dis
}
}
+ private @Nls @NotNull String getModifiedActionName() {
+ String elementName = myViewExtension.getElementsCapitalisedName();
+ ModifiedFilesFilter filter = getModifiedFilesFilter();
+ String branchName = Objects.requireNonNull(filter).getBranchName();
+ if (branchName != null) {
+ return CoverageBundle.message("coverage.show.only.elements.in.feature.branch", elementName, branchName);
+ } else {
+ return CoverageBundle.message("coverage.show.only.modified.elements", elementName);
+ }
+ }
+
private void select(Object object) {
ReadAction.nonBlocking(() -> {
final PsiElement element = myViewExtension.getElementToSelect(object);
diff --git a/plugins/git4idea/intellij.vcs.git.coverage/intellij.vcs.git.coverage.iml b/plugins/git4idea/intellij.vcs.git.coverage/intellij.vcs.git.coverage.iml
new file mode 100644
index 000000000000..ad8fd7316a81
--- /dev/null
+++ b/plugins/git4idea/intellij.vcs.git.coverage/intellij.vcs.git.coverage.iml
@@ -0,0 +1,23 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/plugins/git4idea/intellij.vcs.git.coverage/resources/intellij.vcs.git.coverage.xml b/plugins/git4idea/intellij.vcs.git.coverage/resources/intellij.vcs.git.coverage.xml
new file mode 100644
index 000000000000..677639601c4f
--- /dev/null
+++ b/plugins/git4idea/intellij.vcs.git.coverage/resources/intellij.vcs.git.coverage.xml
@@ -0,0 +1,11 @@
+
+
+
+
+
+
+
+
+
diff --git a/plugins/git4idea/intellij.vcs.git.coverage/src/com/intellij/vcs/git/coverage/CurrentFeatureBranchBaseDetector.kt b/plugins/git4idea/intellij.vcs.git.coverage/src/com/intellij/vcs/git/coverage/CurrentFeatureBranchBaseDetector.kt
new file mode 100644
index 000000000000..8449f4fe8abe
--- /dev/null
+++ b/plugins/git4idea/intellij.vcs.git.coverage/src/com/intellij/vcs/git/coverage/CurrentFeatureBranchBaseDetector.kt
@@ -0,0 +1,146 @@
+// Copyright 2000-2024 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
+package com.intellij.vcs.git.coverage
+
+import com.intellij.vcs.git.coverage.CurrentFeatureBranchBaseDetector.Status
+import com.intellij.openapi.diagnostic.ControlFlowException
+import com.intellij.openapi.diagnostic.thisLogger
+import com.intellij.vcs.log.Hash
+import com.intellij.vcs.log.graph.api.LinearGraph
+import com.intellij.vcs.log.graph.api.LiteLinearGraph
+import com.intellij.vcs.log.graph.api.permanent.PermanentGraphInfo
+import com.intellij.vcs.log.graph.utils.BfsWalk
+import com.intellij.vcs.log.graph.utils.DfsWalk
+import com.intellij.vcs.log.graph.utils.LinearGraphUtils
+import com.intellij.vcs.log.graph.utils.impl.BitSetFlags
+import com.intellij.vcs.log.impl.HashImpl
+import com.intellij.vcs.log.impl.VcsProjectLog
+import git4idea.GitRemoteBranch
+import git4idea.config.GitSharedSettings
+import git4idea.repo.GitRepository
+import org.jetbrains.annotations.VisibleForTesting
+
+internal class CurrentFeatureBranchBaseDetector(private val repository: GitRepository) {
+
+ private val logData = VcsProjectLog.getInstance(repository.project).dataManager
+ private val storage = logData?.storage
+ private val pack = logData?.dataPack
+
+ @Suppress("UNCHECKED_CAST")
+ private val permanentGraph = pack?.permanentGraph as? PermanentGraphInfo
+
+ fun findBaseCommit(): Status {
+ val project = repository.project
+ val remoteBranches = repository.branches.remoteBranches
+ val protectedBranches = remoteBranches.filter { GitSharedSettings.getInstance(project).isBranchProtected(it.nameForRemoteOperations) }
+ if (protectedBranches.isEmpty()) {
+ // This filter can only be applied when there is a pushed protected branch.
+ return Status.NoProtectedBranches
+ }
+
+ val headNodeId = getHeadCommitId() ?: return Status.GitDataNotFound
+ val protectedNodeIds = protectedBranches.associateBy { branch ->
+ val hash = repository.branches.getHash(branch) ?: return Status.GitDataNotFound
+ val nodeId = getCommitNodeId(hash) ?: return Status.GitDataNotFound
+ nodeId
+ }
+
+ val linearGraph = permanentGraph?.linearGraph ?: return Status.GitDataNotFound
+ return when (val status = findBaseCommit(linearGraph, headNodeId, protectedNodeIds.keys)) {
+ is Status.InternalSuccess -> {
+ val commits = status.commits.map { (commitId, protectedBranchId) ->
+ val hash = getHash(commitId) ?: return Status.GitDataNotFound
+ val branch = protectedNodeIds[protectedBranchId] ?: return Status.GitDataNotFound
+ BaseCommitAndBranch(hash, branch)
+ }
+ Status.Success(commits)
+ }
+ else -> status
+ }
+ }
+
+ private fun getHeadCommitId(): Int? {
+ val headRevision = repository.currentRevision ?: return null
+ val headHash = try {
+ HashImpl.build(headRevision)
+ } catch (e: Throwable) {
+ if (e is ControlFlowException) {
+ throw e
+ }
+ thisLogger().warn(e)
+ return null
+ }
+ return getCommitNodeId(headHash)
+ }
+
+ private fun getCommitNodeId(hash: Hash): Int? {
+ val commitIndex = storage?.getCommitIndex(hash, repository.root) ?: return null
+ val nodeId = permanentGraph?.permanentCommitsInfo?.getNodeId(commitIndex) ?: return null
+ return nodeId.takeIf { it >= 0 }
+ }
+
+ private fun getHash(nodeId: Int): Hash? {
+ val commitId = permanentGraph?.permanentCommitsInfo?.getCommitId(nodeId) ?: return null
+ val commit = storage?.getCommitId(commitId)
+ return commit?.hash
+ }
+
+ internal sealed interface Status {
+ data class Success(val commits: List) : Status
+ data class InternalSuccess(val commits: List) : Status
+ data object NoProtectedBranches : Status
+ data object HeadInProtectedBranch : Status
+ data object GitDataNotFound : Status
+ data object CommitHasNoProtectedParents : Status
+ }
+
+ internal data class BaseCommit(val commitId: Int, val protectedNodeId: Int)
+ internal data class BaseCommitAndBranch(val hash: Hash, val protectedBranch: GitRemoteBranch)
+}
+
+@VisibleForTesting
+internal fun findBaseCommit(linearGraph: LinearGraph, headNodeId: Int, protectedNodeIds: Set): Status {
+ val graph = LinearGraphUtils.asLiteLinearGraph(linearGraph)
+
+ val visited = BitSetFlags(graph.nodesCount())
+ if (findProtectedBranchNodeId(headNodeId, graph, visited, protectedNodeIds) != null) {
+ // The current commit is already a part of a protected branch.
+ // No feature branch filtering could be applied.
+ return Status.HeadInProtectedBranch
+ }
+ else {
+ visited.set(headNodeId, false)
+ }
+
+ val bfsWalk = BfsWalk(headNodeId, graph, visited)
+ val foundCommits = mutableListOf()
+ while (true) {
+ val nextLayer = bfsWalk.step()
+ if (nextLayer.isEmpty()) break
+ val protectedCommits = hashSetOf()
+ for (commit in nextLayer) {
+ val protectedNodeId = findProtectedBranchNodeId(commit, graph, visited, protectedNodeIds) ?: continue
+ foundCommits += CurrentFeatureBranchBaseDetector.BaseCommit(commit, protectedNodeId)
+ protectedCommits.add(commit)
+ }
+ // reset visited marks to continue searching down
+ for (nodeId in nextLayer) {
+ if (nodeId in protectedCommits) continue
+ visited.set(nodeId, false)
+ }
+ }
+ if (foundCommits.isEmpty()) return Status.CommitHasNoProtectedParents
+ return Status.InternalSuccess(foundCommits)
+}
+
+private fun findProtectedBranchNodeId(commitId: Int, linearGraph: LiteLinearGraph, visited: BitSetFlags, protectedNodeIds: Set): Int? {
+ var protectedNodeId: Int? = null
+ DfsWalk(listOf(commitId), linearGraph, visited).walk(goDown = false) { nodeId ->
+ if (nodeId in protectedNodeIds) {
+ protectedNodeId = nodeId
+ false
+ }
+ else true
+ }
+ return protectedNodeId
+}
+
diff --git a/plugins/git4idea/intellij.vcs.git.coverage/src/com/intellij/vcs/git/coverage/GitModifiedFilesFilterFactory.kt b/plugins/git4idea/intellij.vcs.git.coverage/src/com/intellij/vcs/git/coverage/GitModifiedFilesFilterFactory.kt
new file mode 100644
index 000000000000..fd49f0e8ea4e
--- /dev/null
+++ b/plugins/git4idea/intellij.vcs.git.coverage/src/com/intellij/vcs/git/coverage/GitModifiedFilesFilterFactory.kt
@@ -0,0 +1,72 @@
+// Copyright 2000-2024 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
+package com.intellij.vcs.git.coverage
+
+import com.intellij.coverage.filters.ModifiedFilesFilter
+import com.intellij.coverage.filters.ModifiedFilesFilterFactory
+import com.intellij.openapi.project.Project
+import com.intellij.openapi.util.registry.Registry
+import com.intellij.openapi.vfs.VirtualFile
+import com.intellij.util.concurrency.annotations.RequiresBackgroundThread
+import com.intellij.vcs.log.Hash
+import git4idea.GitRemoteBranch
+import git4idea.GitUtil
+import git4idea.changes.GitChangeUtils
+import git4idea.repo.GitRepository
+
+internal class GitModifiedFilesFilterFactory : ModifiedFilesFilterFactory {
+ override fun createFilter(project: Project): ModifiedFilesFilter? {
+ if (!Registry.`is`("coverage.filter.based.on.feature.branch")) return null
+ return createGitFilter(project)
+ }
+}
+
+@RequiresBackgroundThread
+private fun createGitFilter(project: Project): ModifiedFilesFilter? {
+ val repositories = GitUtil.getRepositories(project)
+ val filters = repositories.mapNotNull { createFilterForRepository(it) }
+ if (filters.isEmpty()) return null
+ return filters.singleOrNull() ?: MultiRepoGitModifiedFilesFilter(project, filters)
+}
+
+private fun createFilterForRepository(repository: GitRepository): GitModifiedFilesFilter? {
+ val candidates = findBaseCommitCandidates(repository) ?: return null
+ return candidates.mapNotNull { (hash, branch) ->
+ val scope = createModifiedScope(repository, hash) ?: return@mapNotNull null
+ GitModifiedFilesFilter(repository.project, scope, branch)
+ }.minByOrNull { it.modifiedScope.size }
+}
+
+private fun findBaseCommitCandidates(repository: GitRepository): List? {
+ return when (val baseCommit = CurrentFeatureBranchBaseDetector(repository).findBaseCommit()) {
+ is CurrentFeatureBranchBaseDetector.Status.Success -> baseCommit.commits
+ else -> null
+ }
+}
+
+private fun createModifiedScope(repository: GitRepository, baseRevision: Hash): Set? {
+ val currentRevision = repository.currentRevision ?: return null
+ val diff = GitChangeUtils.getDiff(repository, baseRevision.asString(), currentRevision, false) ?: return null
+ val scope = hashSetOf()
+ for (change in diff) {
+ val virtualFile = change.afterRevision?.file?.virtualFile ?: continue
+ scope.add(virtualFile)
+ }
+ return scope
+}
+
+private class GitModifiedFilesFilter(
+ project: Project,
+ val modifiedScope: Set,
+ private val branch: GitRemoteBranch,
+) : ModifiedFilesFilter(project) {
+ override fun isInModifiedScope(file: VirtualFile) = file in modifiedScope
+ override fun getBranchName(): String = branch.nameForLocalOperations
+}
+
+private class MultiRepoGitModifiedFilesFilter(
+ project: Project,
+ private val filters: List,
+) : ModifiedFilesFilter(project) {
+ override fun isInModifiedScope(file: VirtualFile) = filters.any { it.isFileModified(file) }
+ override fun getBranchName() = "Protected"
+}
diff --git a/plugins/git4idea/intellij.vcs.git.coverage/testSrc/com/intellij/vcs/git/coverage/CurrentFeatureBranchBaseDetectorTest.kt b/plugins/git4idea/intellij.vcs.git.coverage/testSrc/com/intellij/vcs/git/coverage/CurrentFeatureBranchBaseDetectorTest.kt
new file mode 100644
index 000000000000..0422974ce711
--- /dev/null
+++ b/plugins/git4idea/intellij.vcs.git.coverage/testSrc/com/intellij/vcs/git/coverage/CurrentFeatureBranchBaseDetectorTest.kt
@@ -0,0 +1,136 @@
+// Copyright 2000-2024 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
+package com.intellij.vcs.git.coverage
+
+import com.intellij.vcs.log.graph.api.LinearGraph
+import com.intellij.vcs.log.graph.graph
+import org.junit.jupiter.api.Assertions.assertEquals
+import org.junit.jupiter.api.Test
+
+
+internal class CurrentFeatureBranchBaseDetectorTest {
+ @Test
+ fun `test linear history`() {
+ // 0 <- HEAD
+ // 1
+ // 2 <- master
+ // 3
+ val graph = graph {
+ 0(1)
+ 1(2)
+ 2(3)
+ 3()
+ }
+ assertCommitFound(graph, 2, 2, setOf(2))
+ }
+
+ @Test
+ fun `test master ahead`() {
+ // 0 <- HEAD
+ // 1 2 <- master
+ // | 3
+ // 4 /
+ val graph = graph {
+ 0(1)
+ 1(4)
+ 2(3)
+ 3(4)
+ 4()
+ }
+ assertCommitFound(graph, 4, 2, setOf(2))
+ }
+
+ @Test
+ fun `test merge with master`() {
+ // 0 <- HEAD
+ // 1 (merge)
+ // | \
+ // 2 3 <- master
+ // 4 /
+ val graph = graph {
+ 0(1)
+ 1(2, 3)
+ 2(4)
+ 3(4)
+ 4()
+ }
+ assertCommitFound(graph, 3, 3, setOf(3))
+ }
+
+ @Test
+ fun `test master is ahead after merge`() {
+ // 0 <- HEAD
+ // 1 (merge) 3 <- master
+ // | \ /
+ // 2 4
+ // 5 /
+ val graph = graph {
+ 0(1)
+ 1(2, 4)
+ 2(5)
+ 3(4)
+ 4(5)
+ 5()
+ }
+ assertCommitFound(graph, 4, 3, setOf(3))
+ }
+
+ @Test
+ fun `test merge with other protected branch`() {
+ // Here
+ // 0 <- HEAD
+ // 1 (merge)
+ // | \
+ // 2 4 <- protected
+ // 3
+ // 5 <- master
+ val graph = graph {
+ 0(1)
+ 1(2, 4)
+ 2(3)
+ 3(5)
+ 4()
+ 5()
+ }
+ val expected = listOf(CurrentFeatureBranchBaseDetector.BaseCommit(4, 4), CurrentFeatureBranchBaseDetector.BaseCommit(5, 5))
+ assertCommitFound(graph, expected, setOf(4, 5))
+ }
+
+ @Test
+ fun `test commit in protected branch`() {
+ // 0 <- master
+ // 1
+ // 2 <- HEAD
+ // 3
+ val graph = graph {
+ 0(1)
+ 1(2)
+ 2(3)
+ 3()
+ }
+ assertEquals(CurrentFeatureBranchBaseDetector.Status.HeadInProtectedBranch, findBaseCommit(graph, 2, setOf(0)))
+ }
+
+ @Test
+ fun `test protected branch in inaccessable`() {
+ // 0 <- HEAD
+ // 1
+ // 2
+ // 3
+ val graph = graph {
+ 0(1)
+ 1(2)
+ 2(3)
+ 3()
+ }
+ assertEquals(CurrentFeatureBranchBaseDetector.Status.CommitHasNoProtectedParents, findBaseCommit(graph, 0, emptySet()))
+ }
+
+ private fun assertCommitFound(graph: LinearGraph, expectedCommitId: Int, expectedProtectedId: Int, protectedNodeIds: Set) {
+ assertCommitFound(graph, listOf(CurrentFeatureBranchBaseDetector.BaseCommit(expectedCommitId, expectedProtectedId)), protectedNodeIds)
+ }
+
+ private fun assertCommitFound(graph: LinearGraph, commits: List, protectedNodeIds: Set) {
+ val baseCommit = findBaseCommit(graph, 0, protectedNodeIds)
+ assertEquals(CurrentFeatureBranchBaseDetector.Status.InternalSuccess(commits), baseCommit)
+ }
+}
diff --git a/plugins/git4idea/resources/META-INF/plugin.xml b/plugins/git4idea/resources/META-INF/plugin.xml
index df9d29b17481..48734ee12035 100644
--- a/plugins/git4idea/resources/META-INF/plugin.xml
+++ b/plugins/git4idea/resources/META-INF/plugin.xml
@@ -26,6 +26,7 @@
+