diff --git a/plugins/git4idea/resources/META-INF/plugin.xml b/plugins/git4idea/resources/META-INF/plugin.xml
index 53173bc9c708..5c07cd0b1fac 100644
--- a/plugins/git4idea/resources/META-INF/plugin.xml
+++ b/plugins/git4idea/resources/META-INF/plugin.xml
@@ -105,6 +105,7 @@
+ .
diff --git a/plugins/git4idea/src/git4idea/branch/GitCleanupBranchesAction.kt b/plugins/git4idea/src/git4idea/branch/GitCleanupBranchesAction.kt
new file mode 100644
index 000000000000..6d07cd8382fd
--- /dev/null
+++ b/plugins/git4idea/src/git4idea/branch/GitCleanupBranchesAction.kt
@@ -0,0 +1,450 @@
+// Copyright 2000-2019 JetBrains s.r.o. Use of this source code is governed by the Apache 2.0 license that can be found in the LICENSE file.
+package git4idea.branch
+
+import com.intellij.dvcs.DvcsUtil
+import com.intellij.icons.AllIcons
+import com.intellij.openapi.Disposable
+import com.intellij.openapi.actionSystem.*
+import com.intellij.openapi.diagnostic.logger
+import com.intellij.openapi.keymap.KeymapUtil.*
+import com.intellij.openapi.progress.ProgressIndicator
+import com.intellij.openapi.progress.Task
+import com.intellij.openapi.progress.util.ProgressWindow
+import com.intellij.openapi.project.DumbAware
+import com.intellij.openapi.project.DumbAwareAction
+import com.intellij.openapi.project.Project
+import com.intellij.openapi.util.Disposer
+import com.intellij.openapi.wm.IdeFocusManager
+import com.intellij.openapi.wm.ToolWindowId
+import com.intellij.openapi.wm.ToolWindowManager
+import com.intellij.ui.*
+import com.intellij.ui.components.JBList
+import com.intellij.ui.components.JBLoadingPanel
+import com.intellij.ui.content.ContentFactory
+import com.intellij.ui.speedSearch.NameFilteringListModel
+import com.intellij.ui.speedSearch.SpeedSearch
+import com.intellij.util.ContentsUtil
+import com.intellij.util.ThreeState
+import com.intellij.util.ThreeState.UNSURE
+import com.intellij.util.ui.components.BorderLayoutPanel
+import com.intellij.vcs.log.VcsLogProperties
+import com.intellij.vcs.log.data.DataPackChangeListener
+import com.intellij.vcs.log.graph.impl.facade.PermanentGraphImpl
+import com.intellij.vcs.log.graph.impl.facade.ReachableNodes
+import com.intellij.vcs.log.graph.utils.DfsWalk
+import com.intellij.vcs.log.graph.utils.LinearGraphUtils
+import com.intellij.vcs.log.impl.VcsProjectLog
+import com.intellij.vcs.log.util.VcsLogUtil
+import com.intellij.vcs.log.util.findBranch
+import com.intellij.vcs.log.visible.filters.VcsLogFilterObject
+import com.intellij.vcs.log.visible.filters.VcsLogUserFilterImpl
+import git4idea.config.GitSharedSettings
+import git4idea.repo.GitRepository
+import git4idea.repo.GitRepositoryManager
+import gnu.trove.TIntHashSet
+import java.awt.BorderLayout
+import java.awt.event.KeyEvent
+import javax.swing.*
+import javax.swing.event.DocumentEvent
+
+private val LOG = logger()
+
+class GitCleanupBranchesAction : DumbAwareAction() {
+
+ override fun actionPerformed(e: AnActionEvent) {
+ val project = e.project!!
+ val branches = getAllBranches(project)
+
+ val toolWindow = ToolWindowManager.getInstance(project).getToolWindow(ToolWindowId.VCS)
+ val ui = BranchesUi(project, VcsProjectLog.getInstance(project), branches)
+ val content = ContentFactory.SERVICE.getInstance().createContent(ui.getComponent(), "Cleanup Branches", true)
+ ContentsUtil.addContent(toolWindow.contentManager, content, true)
+ Disposer.register(content, ui)
+ toolWindow.activate(null, true, true)
+
+ VcsLogUtil.runWhenLogIsReady(project) { _, _ ->
+ ui.stopLoading()
+ } // schedule initialization: need the log for other actions
+ }
+}
+
+private data class BranchInfo(val branchName: String,
+ val isLocal: Boolean,
+ val repositories: List) {
+ var isMy: ThreeState = UNSURE
+}
+
+private class BranchesUi(val project: Project, val log: VcsProjectLog, initialBranches: List) : Disposable {
+ val branches = HashSet(initialBranches)
+ var showOnlyMy: Boolean = false
+
+ val model = CollectionListModel(sort(branches))
+ val list = BranchesList(project)
+ val panel = MyPanel(list)
+ val loadingPanel = JBLoadingPanel(BorderLayout(), this, ProgressWindow.DEFAULT_PROGRESS_DIALOG_POSTPONE_TIME_MILLIS)
+
+ val speedSearch = SpeedSearch()
+ val filteringListModel = NameFilteringListModel(
+ model, { it.branchName }, speedSearch::shouldBeShowing, { speedSearch.filter ?: "" })
+
+ val changeListener = DataPackChangeListener {
+ val newBranches = getAllBranches(project)
+ branches.retainAll(newBranches)
+ branches.addAll(newBranches)
+ updateMyBranchesIfNeeded()
+ }
+
+ init {
+ filteringListModel.setFilter { t: BranchInfo? -> t != null && speedSearch.shouldBeShowing(t.branchName)
+ && (!showOnlyMy || t.isMy == ThreeState.YES) }
+
+ list.model = filteringListModel
+
+ speedSearch.addChangeListener {
+ filteringListModel.refilter()
+ }
+
+ log.dataManager?.addDataPackChangeListener(changeListener)
+ }
+
+ fun getComponent() : JComponent {
+ val searchField = object : SearchTextField(false) {
+ override fun preprocessEventForTextField(e: KeyEvent): Boolean {
+ if (e.keyCode == KeyEvent.VK_DOWN || e.keyCode == KeyEvent.VK_UP) {
+ list.dispatchEvent(e)
+ return true
+ }
+ return false
+ }
+ }
+ searchField.addDocumentListener(object : DocumentAdapter() {
+ override fun textChanged(e: DocumentEvent) {
+ speedSearch.updatePattern(searchField.text)
+ }
+ })
+
+ val diffAction = ShowBranchDiffAction()
+ diffAction.registerCustomShortcutSet(getActiveKeymapShortcuts("Diff.ShowDiff"), panel)
+
+ val deleteAction = DeleteBranchAction()
+ val shortcuts = getActiveKeymapShortcuts("SafeDelete").shortcuts + getActiveKeymapShortcuts("EditorDeleteToLineStart").shortcuts
+ deleteAction.registerCustomShortcutSet(CustomShortcutSet(*shortcuts), panel)
+
+ createFocusFilterFieldAction(searchField)
+
+ val showMyBranchesAction = ShowMyBranchesAction()
+
+ val group = DefaultActionGroup()
+ group.add(deleteAction)
+ group.add(diffAction)
+ group.add(showMyBranchesAction)
+ val toolbar = ActionManager.getInstance().createActionToolbar("Git.Cleanup.Branches", group, false)
+ toolbar.setTargetComponent(list)
+
+ panel.addToTop(searchField)
+ panel.addToLeft(toolbar.component)
+ panel.addToCenter(ScrollPaneFactory.createScrollPane(list, true))
+
+ loadingPanel.add(panel)
+ loadingPanel.startLoading()
+ return loadingPanel
+ }
+
+ private fun createFocusFilterFieldAction(searchField: SearchTextField) {
+ DumbAwareAction.create { e ->
+ val project = e.getRequiredData(CommonDataKeys.PROJECT)
+ if (IdeFocusManager.getInstance(project).getFocusedDescendantFor(list) != null) {
+ IdeFocusManager.getInstance(project).requestFocus(searchField, true)
+ }
+ else {
+ IdeFocusManager.getInstance(project).requestFocus(list, true)
+ }
+ }.registerCustomShortcutSet(getActiveKeymapShortcuts("Find"), panel)
+ }
+
+ override fun dispose() {
+ log.dataManager?.removeDataPackChangeListener(changeListener)
+ }
+
+ fun stopLoading() {
+ loadingPanel.stopLoading()
+ }
+
+ fun updateMyBranchesIfNeeded() {
+ if (showOnlyMy) {
+ loadingPanel.startLoading()
+ panel.isEnabled = false
+
+ val branchesToCheck = branches.filter { it.isMy == UNSURE }
+ object: Task.Backgroundable(project, "Calculating My Branches", true) {
+ var myBranches : List? = null
+ override fun run(indicator: ProgressIndicator) {
+ myBranches = checkBranchesSynchronously(log, branchesToCheck, indicator)
+ }
+
+ override fun onSuccess() {
+ if (myBranches == null) return
+
+ for (branch in branches) {
+ if (branch.isMy == UNSURE) {
+ branch.isMy = if (myBranches!!.contains(branch)) ThreeState.YES else ThreeState.NO
+ }
+ }
+
+ updateListAccordingToCurrentlyKnownBranches()
+ }
+
+ override fun onFinished() {
+ panel.isEnabled = true
+ loadingPanel.stopLoading()
+ }
+ }.queue()
+ }
+ else {
+ updateListAccordingToCurrentlyKnownBranches()
+ }
+ }
+
+ private fun updateListAccordingToCurrentlyKnownBranches() {
+ preserveSelection {
+ model.replaceAll(sort(branches))
+ filteringListModel.refilter()
+ }
+ }
+
+ private fun preserveSelection(action: () -> Unit) {
+ val selectedValue = list.selectedValue
+ val selectedIndex = list.selectedIndex
+
+ action()
+
+ var newSelectedIndex = -1
+ for (i in 0 until list.model.size) {
+ val element = list.model.getElementAt(i)
+ if (element == selectedValue) {
+ newSelectedIndex = i
+ break
+ }
+ }
+ list.selectedIndex = if (newSelectedIndex > 0) newSelectedIndex else selectedIndex
+ }
+
+ private inner class ShowMyBranchesAction : ToggleAction("Show My Branches", null, AllIcons.Actions.Find), DumbAware {
+ override fun isSelected(e: AnActionEvent): Boolean {
+ return showOnlyMy
+ }
+
+ override fun setSelected(e: AnActionEvent, state: Boolean) {
+ showOnlyMy = state
+ updateMyBranchesIfNeeded()
+ }
+
+ override fun update(e: AnActionEvent) {
+ super.update(e)
+ val log = VcsProjectLog.getInstance(project)
+ val supportsIndexing = log.dataManager?.logProviders?.all {
+ VcsLogProperties.get(it.value, VcsLogProperties.SUPPORTS_INDEXING) } ?: false
+
+ val isGraphReady = log.dataManager?.dataPack?.isFull ?: false
+
+ val allRootsIndexed = GitRepositoryManager.getInstance(project).repositories.all {
+ log.dataManager?.index?.isIndexed(it.root) ?: false
+ }
+
+ e.presentation.isEnabled = supportsIndexing && isGraphReady && allRootsIndexed
+ e.presentation.description = if (!supportsIndexing) {
+ "Some of repositories doesn't support indexing."
+ }
+ else if (!allRootsIndexed) {
+ "Not all repositories are indexed."
+ }
+ else if (!isGraphReady) {
+ "The log is not ready yet, please wait a bit."
+ }
+ else {
+ "A branch is 'My' if all exclusive commits of this branch are made by 'me', i.e. by current Git author."
+ }
+ }
+ }
+}
+
+private class MyPanel(val list: BranchesList) : BorderLayoutPanel(), DataProvider {
+ override fun getData(dataId: String): Any? {
+ if (GIT_BRANCH.`is`(dataId)) {
+ return list.selectedValue
+ }
+ return null
+ }
+}
+
+private class BranchesList(project: Project) : JBList() {
+ init {
+ selectionMode = ListSelectionModel.SINGLE_SELECTION
+ cellRenderer = object : ColoredListCellRenderer() {
+ override fun customizeCellRenderer(list: JList, value: BranchInfo?, index: Int, selected: Boolean, hasFocus: Boolean) {
+ if (value == null) {
+ return
+ }
+ append(value.branchName)
+ if (value.repositories.size < GitRepositoryManager.getInstance(project).repositories.size) {
+ append(" (${DvcsUtil.getShortNames(value.repositories)})", SimpleTextAttributes.GRAYED_ATTRIBUTES)
+ }
+ }
+ }
+ }
+}
+
+private class DeleteBranchAction : CleanupBranchesActionBase("Delete Branch", null, AllIcons.Actions.GC) {
+ override fun actionPerformed(e: AnActionEvent) {
+ val branch = e.getData(GIT_BRANCH)!!
+ val project = e.project!!
+ val brancher = GitBrancher.getInstance(project)
+
+ if (branch.isLocal) {
+ brancher.deleteBranch(branch.branchName, branch.repositories)
+ }
+ else {
+ brancher.deleteRemoteBranch(branch.branchName, branch.repositories)
+ }
+ }
+}
+
+private class ShowBranchDiffAction : CleanupBranchesActionBase("Compare with Current", null, AllIcons.Actions.Diff) {
+ override fun actionPerformed(e: AnActionEvent) {
+ val branch = e.getData(GIT_BRANCH)!!
+ val project = e.project!!
+
+ VcsLogUtil.runWhenLogIsReady(project) { log, logManager ->
+ val filters = VcsLogFilterObject.fromRange("HEAD", branch.branchName)
+ log.tabsManager.openAnotherLogTab(logManager, VcsLogFilterObject.collection(filters))
+ }
+ }
+}
+
+private abstract class CleanupBranchesActionBase(text: String, description: String?, icon: Icon) :
+ DumbAwareAction(text, description, icon) {
+
+ override fun update(e: AnActionEvent) {
+ super.update(e)
+ e.presentation.isEnabled = e.project != null && e.getData(GIT_BRANCH) != null
+ }
+}
+
+private val GIT_BRANCH = DataKey.create("GitBranchKey")
+
+private fun getAllBranches(project: Project): List {
+ val settings = GitSharedSettings.getInstance(project)
+
+ val localMap = mutableMapOf>()
+ for (repo in GitRepositoryManager.getInstance(project).repositories) {
+ for (branch in repo.branches.localBranches) {
+ if (repo.currentBranch != branch) {
+ val trackedBranch = branch.findTrackedBranch(repo)
+ if (trackedBranch == null || !settings.isBranchProtected(trackedBranch.nameForRemoteOperations)) {
+ localMap.computeIfAbsent(branch.name) { mutableListOf() }.add(repo)
+ }
+ }
+ }
+ }
+ val local = localMap.map { (branchName, repos) -> BranchInfo(branchName, true, repos) }
+
+ val remoteMap = mutableMapOf>()
+ for (repo in GitRepositoryManager.getInstance(project).repositories) {
+ for (remoteBranch in repo.branches.remoteBranches) {
+ if (!settings.isBranchProtected(remoteBranch.nameForRemoteOperations)) {
+ remoteMap.computeIfAbsent(remoteBranch.name) { mutableListOf() }.add(repo)
+ }
+ }
+ }
+ val remote = remoteMap.map { (branchName, repos) -> BranchInfo(branchName, false, repos) }
+
+ return local + remote
+}
+
+private fun sort(branches: HashSet): List {
+ return branches.sortedWith(Comparator { o1, o2 ->
+ if (o1.isLocal && !o2.isLocal) -1
+ else if (!o1.isLocal && o2.isLocal) 1
+ else {
+ o1.branchName.compareTo(o2.branchName)
+ }
+ })
+}
+
+private fun checkBranchesSynchronously(log: VcsProjectLog,
+ branchesToCheck: Collection,
+ indicator: ProgressIndicator): List? {
+ val myCommits = findMyCommits(log)
+ if (myCommits == null) return null
+
+ indicator.isIndeterminate = false
+ val branches = mutableListOf()
+ for ((step, branch) in branchesToCheck.withIndex()) {
+ indicator.fraction = step.toDouble() / branchesToCheck.size
+
+ for (repo in branch.repositories) {
+ indicator.checkCanceled()
+ if (isMyBranch(log, branch.branchName, repo, myCommits)) {
+ branches.add(branch)
+ }
+ }
+ }
+
+ return branches
+}
+
+fun findMyCommits(log: VcsProjectLog): Set? {
+ val filterByMe = VcsLogFilterObject.fromUserNames(listOf(VcsLogUserFilterImpl.ME), log.dataManager!!)
+ return log.dataManager!!.index.dataGetter!!.filter(listOf(filterByMe))
+}
+
+private fun isMyBranch(log: VcsProjectLog,
+ branchName: String,
+ repo: GitRepository,
+ myCommits: Set): Boolean {
+ // branch is "my" if all its exclusive commits are made by me
+ val exclusiveCommits = findExclusiveCommits(log, branchName, repo) ?: return false
+ if (exclusiveCommits.isEmpty) return false
+
+ for (commit in exclusiveCommits) {
+ if (!myCommits.contains(commit)) {
+ LOG.debug("Commit ${log.dataManager!!.storage.getCommitId(commit)} is not mine")
+ return false
+ }
+ }
+
+ return true
+}
+
+private fun findExclusiveCommits(log: VcsProjectLog, branchName: String, repo: GitRepository): TIntHashSet? {
+ val dataPack = log.dataManager!!.dataPack
+ val permanentGraph = dataPack.permanentGraph as PermanentGraphImpl
+ val storage = log.dataManager!!.storage
+
+ val ref = dataPack.refsModel.findBranch(branchName, repo.root) ?: return null
+ if (!ref.type.isBranch) return null
+
+ val branchRefIndex = storage.getCommitIndex(ref.commitHash, ref.root)
+ val branchNodeId = permanentGraph.permanentCommitsInfo.getNodeId(branchRefIndex)
+
+ val exclusiveCommits = TIntHashSet()
+ DfsWalk(listOf(branchNodeId), permanentGraph.linearGraph).walk(true) { node: Int ->
+ val reachableNodes = ReachableNodes(LinearGraphUtils.asLiteLinearGraph(permanentGraph.linearGraph))
+ val containingBranchesIndexes = reachableNodes.getContainingBranches(node, permanentGraph.branchNodeIds)
+
+ if (!containingBranchesIndexes.contains(branchNodeId)) {
+ LOG.error("The branch $branchName doesn't contain commit reachable from it")
+ }
+
+ if (containingBranchesIndexes.size > 1) {
+ // there are other containing branches
+ false
+ }
+ else {
+ val commitId = permanentGraph.permanentCommitsInfo.getCommitId(node)
+ exclusiveCommits.add(commitId)
+ true
+ }
+ }
+ return exclusiveCommits
+}
\ No newline at end of file