diff --git a/plugins/git4idea/backend/src/remoteApi/GitRepositoryToDtoConverter.kt b/plugins/git4idea/backend/src/remoteApi/GitRepositoryToDtoConverter.kt index 2bb360ee71fbe..17b33811b52d3 100644 --- a/plugins/git4idea/backend/src/remoteApi/GitRepositoryToDtoConverter.kt +++ b/plugins/git4idea/backend/src/remoteApi/GitRepositoryToDtoConverter.kt @@ -12,6 +12,7 @@ import com.intellij.vcs.git.repo.GitOperationState import com.intellij.vcs.git.rpc.GitRepositoryDto import com.intellij.vcs.git.rpc.GitRepositoryStateDto import com.intellij.vcsUtil.VcsUtil +import git4idea.GitStandardLocalBranch import git4idea.GitStandardRemoteBranch import git4idea.branch.GitBranchType import git4idea.branch.GitTagType @@ -45,7 +46,8 @@ internal object GitRepositoryToDtoConverter { workingTrees = repository.workingTreeHolder.getWorkingTrees(), recentBranches = repository.branches.recentCheckoutBranches, operationState = convertOperationState(repository), - trackingInfo = convertTrackingInfo(repoInfo.branchTrackInfosMap) + trackingInfo = convertTrackingInfo(repoInfo.branchTrackInfosMap), + upstreamGoneBranches = repoInfo.upstreamGoneBranches.filterIsInstance().toSet(), ) } diff --git a/plugins/git4idea/backend/src/repo/GitConfig.kt b/plugins/git4idea/backend/src/repo/GitConfig.kt index cc7b7eddd4f72..2d3ae880cb1b4 100644 --- a/plugins/git4idea/backend/src/repo/GitConfig.kt +++ b/plugins/git4idea/backend/src/repo/GitConfig.kt @@ -87,6 +87,20 @@ class GitConfig private constructor( convertBranchConfig(config, localBranches, remoteBranches) }.toSet() + /** + * Returns local branches that have a configured upstream (`branch..merge` + `branch..remote`) + * whose upstream remote branch is no longer present in [remoteBranches] — i.e. git's `gone` state + * (for example, after a `fetch --prune` removed the stale remote-tracking ref). Such branches have no + * resolved [GitBranchTrackInfo] (see [parseTrackInfos]), so this is the only way to detect them. + */ + fun parseUpstreamGoneBranches( + localBranches: Collection, + remoteBranches: Collection, + ): Set = + trackedInfos.mapNotNullTo(HashSet()) { config -> + if (isUpstreamConfiguredButGone(config, remoteBranches)) findLocalBranch(config.name, localBranches) else null + } + /** * Return core info */ @@ -206,6 +220,19 @@ class GitConfig private constructor( return GitBranchTrackInfo(localBranch, remoteBranch, merge) } + private fun isUpstreamConfiguredButGone(branchConfig: BranchConfig, remoteBranches: Collection): Boolean { + val remoteName = branchConfig.remote + val mergeName = branchConfig.merge + val rebaseName = branchConfig.rebase + + if (mergeName.isNullOrBlank() && rebaseName.isNullOrBlank()) return false + if (remoteName.isNullOrBlank()) return false + + val merge = mergeName != null + val remoteBranchName = StringUtil.unquoteString((if (merge) mergeName else rebaseName)!!) + return findRemoteBranch(remoteBranchName, remoteName, remoteBranches) == null + } + private fun findLocalBranch(branchName: String, localBranches: Collection): GitLocalBranch? { val name = GitBranchUtil.stripRefsPrefix(branchName) return localBranches.find { input -> input.name == name } diff --git a/plugins/git4idea/backend/src/repo/GitRepoInfo.kt b/plugins/git4idea/backend/src/repo/GitRepoInfo.kt index 404d9425c3781..713513d953af1 100644 --- a/plugins/git4idea/backend/src/repo/GitRepoInfo.kt +++ b/plugins/git4idea/backend/src/repo/GitRepoInfo.kt @@ -18,7 +18,9 @@ data class GitRepoInfo(val currentBranch: GitLocalBranch?, val branchTrackInfos: Collection, val submodules: Collection, val hooksInfo: GitHooksInfo, - val isShallow: Boolean) { + val isShallow: Boolean, + /** Local branches whose configured upstream no longer exists (git's `gone` state). */ + val upstreamGoneBranches: Set = emptySet()) { val branchTrackInfosMap: Map = branchTrackInfos.associateByTo(CollectionFactory.createCustomHashingStrategyMap(GitReference.BRANCH_NAME_HASHING_STRATEGY)) { it.localBranch.name } diff --git a/plugins/git4idea/backend/src/repo/GitRepositoryImpl.kt b/plugins/git4idea/backend/src/repo/GitRepositoryImpl.kt index ccb027627bc45..6975ec05970cd 100644 --- a/plugins/git4idea/backend/src/repo/GitRepositoryImpl.kt +++ b/plugins/git4idea/backend/src/repo/GitRepositoryImpl.kt @@ -209,6 +209,7 @@ class GitRepositoryImpl private constructor( val remoteBranches = state.remoteBranches val localBranches = state.localBranches val trackInfos = config.parseTrackInfos(state.localBranches.keys, state.remoteBranches.keys) + val upstreamGoneBranches = config.parseUpstreamGoneBranches(state.localBranches.keys, state.remoteBranches.keys) val hooksInfo = repositoryReader.readHooksInfo() val submoduleFile = root.toNioPath().resolve(".gitmodules") @@ -223,7 +224,8 @@ class GitRepositoryImpl private constructor( branchTrackInfos = trackInfos, submodules = submodules, hooksInfo = hooksInfo, - isShallow = isShallow) + isShallow = isShallow, + upstreamGoneBranches = upstreamGoneBranches) } } diff --git a/plugins/git4idea/backend/src/ui/branch/dashboard/BranchesTree.kt b/plugins/git4idea/backend/src/ui/branch/dashboard/BranchesTree.kt index 029a27818479d..b779d6578ae47 100644 --- a/plugins/git4idea/backend/src/ui/branch/dashboard/BranchesTree.kt +++ b/plugins/git4idea/backend/src/ui/branch/dashboard/BranchesTree.kt @@ -21,6 +21,7 @@ import com.intellij.openapi.components.service import com.intellij.openapi.progress.util.BackgroundTaskUtil import com.intellij.openapi.project.Project import com.intellij.openapi.util.Disposer +import com.intellij.openapi.util.NlsSafe import com.intellij.openapi.util.TextRange import com.intellij.openapi.util.registry.Registry import com.intellij.openapi.util.text.StringUtil @@ -53,8 +54,10 @@ import com.intellij.vcs.git.branch.tree.GitBranchesTreeUtil import com.intellij.vcs.git.ui.GitBranchesTreeIconProvider import com.intellij.vcs.git.ui.GitIncomingOutgoingUi import com.intellij.vcsUtil.VcsImplUtil +import com.intellij.xml.util.XmlStringUtil import git4idea.branch.GitBranchIncomingOutgoingManager import git4idea.config.GitVcsSettings +import git4idea.i18n.GitBundle import git4idea.repo.GitRepository import git4idea.repo.GitRepositoryManager import git4idea.ui.branch.dashboard.BranchesDashboardActions.BranchesTreeActionGroup @@ -119,7 +122,8 @@ internal class BranchesTreeComponent(project: Project) : DnDAwareTree() { descriptor.refInfo.ref, current = descriptor.refInfo.isCurrent, favorite = descriptor.refInfo.isFavorite, - selected = selected + selected = selected, + upstreamGone = descriptor.refInfo.isUpstreamGone ) is BranchNodeDescriptor.Group, is BranchNodeDescriptor.RemoteGroup -> GitBranchesTreeIconProvider.forGroup() is BranchNodeDescriptor.Repository -> @@ -139,7 +143,10 @@ internal class BranchesTreeComponent(project: Project) : DnDAwareTree() { if (refInfo is BranchInfo) { toolTipText = - if (refInfo.isLocalBranch) BranchPresentation.getTooltip(getBranchesTooltipData(refInfo.branchName, BranchesTreeSelection.getSelectedRepositories(value))) + if (refInfo.isLocalBranch) { + val trackingTooltip = BranchPresentation.getTooltip(getBranchesTooltipData(refInfo.branchName, BranchesTreeSelection.getSelectedRepositories(value))) + appendUpstreamGoneNote(trackingTooltip, refInfo.isUpstreamGone) + } else null val incomingOutgoingState = refInfo.incomingOutgoingState @@ -169,6 +176,15 @@ internal class BranchesTreeComponent(project: Project) : DnDAwareTree() { } } + private fun appendUpstreamGoneNote(trackingTooltip: @NlsSafe String?, isUpstreamGone: Boolean): @NlsSafe String? { + if (!isUpstreamGone) return trackingTooltip + val note = GitBundle.message("branch.upstream.gone.tooltip") + if (trackingTooltip.isNullOrBlank()) return note + // getTooltip() returns plain text for a single repo and `` rows for multiple repos + val body = if (trackingTooltip.contains("$trackingTooltip" else XmlStringUtil.escapeString(trackingTooltip) + return XmlStringUtil.wrapInHtml("$body
$note") + } + override fun paint(g: Graphics) { super.paint(g) diff --git a/plugins/git4idea/backend/src/ui/branch/dashboard/BranchesTreeModel.kt b/plugins/git4idea/backend/src/ui/branch/dashboard/BranchesTreeModel.kt index 18ed034fd489b..eed43f4bb4ec0 100644 --- a/plugins/git4idea/backend/src/ui/branch/dashboard/BranchesTreeModel.kt +++ b/plugins/git4idea/backend/src/ui/branch/dashboard/BranchesTreeModel.kt @@ -41,6 +41,10 @@ internal sealed interface RefInfo { val ref: GitReference val refName: String get() = ref.name + + /** See [com.intellij.vcs.git.repo.isUpstreamGone]. Always `false` for refs that can't track an upstream (e.g. tags). */ + val isUpstreamGone: Boolean + get() = false } internal data class BranchInfo( @@ -54,6 +58,10 @@ internal data class BranchInfo( val branchName: @NlsSafe String get() = branch.name val isLocalBranch = branch is GitLocalBranch + override val isUpstreamGone: Boolean = (branch as? GitLocalBranch)?.let { local -> + repositories.isNotEmpty() && repositories.all { repo -> local in repo.info.upstreamGoneBranches } + } ?: false + override val ref = branch override fun toString() = branchName diff --git a/plugins/git4idea/shared/resources/messages/GitBundle.properties b/plugins/git4idea/shared/resources/messages/GitBundle.properties index c725b0439507d..80711e69c40b7 100644 --- a/plugins/git4idea/shared/resources/messages/GitBundle.properties +++ b/plugins/git4idea/shared/resources/messages/GitBundle.properties @@ -1361,6 +1361,7 @@ delete.branch.operation.you.may.rollback.not.to.let.branches.diverge=You may rol branch.operation.could.not.0.operation.name.1.reference=Could not {0} {1} branch.operation.in={0} (in {1}) +branch.upstream.gone.tooltip=Upstream branch was deleted checkout.new.branch.operation.could.not.create.new.branch=Could not create new branch {0} checkout.new.branch.operation.error.during.rollback=Error during rollback diff --git a/plugins/git4idea/shared/src/com/intellij/vcs/git/branch/tree/GitBranchesTreeRenderer.kt b/plugins/git4idea/shared/src/com/intellij/vcs/git/branch/tree/GitBranchesTreeRenderer.kt index ed3cbe5b60ce9..5822387c9b8c8 100644 --- a/plugins/git4idea/shared/src/com/intellij/vcs/git/branch/tree/GitBranchesTreeRenderer.kt +++ b/plugins/git4idea/shared/src/com/intellij/vcs/git/branch/tree/GitBranchesTreeRenderer.kt @@ -24,9 +24,11 @@ import com.intellij.vcs.git.branch.popup.GitBranchesPopupStepBase import com.intellij.vcs.git.branch.tree.GitBranchesTreeModel.RefUnderRepository import com.intellij.vcs.git.branch.tree.GitBranchesTreeUtil.canHighlight import com.intellij.vcs.git.repo.GitRepositoryModel +import com.intellij.vcs.git.repo.isUpstreamGone import com.intellij.vcs.git.ui.GitBranchesTreeIconProvider import git4idea.GitBranch import git4idea.GitReference +import git4idea.GitStandardLocalBranch import org.jetbrains.annotations.ApiStatus import java.awt.Component import java.awt.Graphics2D @@ -66,8 +68,11 @@ abstract class GitBranchesTreeRenderer( selected: Boolean): Icon { val isCurrent = repositories.all { it.state.isCurrentRef(reference) } val isFavorite = repositories.all { it.favoriteRefs.contains(reference) } + val localBranch = reference as? GitStandardLocalBranch + val isUpstreamGone = localBranch != null && + repositories.isNotEmpty() && repositories.all { it.state.isUpstreamGone(localBranch) } - return GitBranchesTreeIconProvider.forRef(reference, current = isCurrent, favorite = isFavorite, favoriteToggleOnClick = favoriteToggleOnClickSupported, selected = selected) + return GitBranchesTreeIconProvider.forRef(reference, current = isCurrent, favorite = isFavorite, favoriteToggleOnClick = favoriteToggleOnClickSupported, selected = selected, upstreamGone = isUpstreamGone) } private fun getNodeIcon(treeNode: Any?, isSelected: Boolean): Icon? { diff --git a/plugins/git4idea/shared/src/com/intellij/vcs/git/repo/GitRepositoriesHolder.kt b/plugins/git4idea/shared/src/com/intellij/vcs/git/repo/GitRepositoriesHolder.kt index c96991c4cf301..15b2499ad68cb 100644 --- a/plugins/git4idea/shared/src/com/intellij/vcs/git/repo/GitRepositoriesHolder.kt +++ b/plugins/git4idea/shared/src/com/intellij/vcs/git/repo/GitRepositoriesHolder.kt @@ -183,6 +183,7 @@ class GitRepositoriesHolder( recentBranches = repositoryStateDto.recentBranches, operationState = repositoryStateDto.operationState, trackingInfo = repositoryStateDto.trackingInfo, + upstreamGoneBranches = repositoryStateDto.upstreamGoneBranches, ) private fun getUpdateType(rpcEvent: GitRepositoryEvent): UpdateType? = when (rpcEvent) { @@ -223,6 +224,7 @@ private class GitRepositoryStateImpl( override val recentBranches: List, override val operationState: GitOperationState, private val trackingInfo: Map, + override val upstreamGoneBranches: Set, ) : GitRepositoryState { override fun getTrackingInfo(branch: GitStandardLocalBranch): GitStandardRemoteBranch? = trackingInfo[branch.name] diff --git a/plugins/git4idea/shared/src/com/intellij/vcs/git/repo/GitRepositoryState.kt b/plugins/git4idea/shared/src/com/intellij/vcs/git/repo/GitRepositoryState.kt index 064014ec07141..e5cc0793c921c 100644 --- a/plugins/git4idea/shared/src/com/intellij/vcs/git/repo/GitRepositoryState.kt +++ b/plugins/git4idea/shared/src/com/intellij/vcs/git/repo/GitRepositoryState.kt @@ -22,6 +22,10 @@ interface GitRepositoryState { val recentBranches: List val operationState: GitOperationState + /** Local branches whose configured upstream no longer exists (git's `gone` state). See [isUpstreamGone]. */ + val upstreamGoneBranches: Set + get() = emptySet() + val currentBranch: GitStandardLocalBranch? get() = (currentRef as? GitCurrentRef.LocalBranch)?.branch /** @@ -37,3 +41,12 @@ interface GitRepositoryState { fun getTrackingInfo(branch: GitStandardLocalBranch): GitStandardRemoteBranch? } + +/** + * A local branch is considered orphaned ("upstream gone") when it has a configured upstream + * (tracking info), but that upstream branch is no longer present among the known remote branches. + * This matches git's `gone` status (e.g. `git branch -vv` showing `[origin/x: gone]`), which becomes + * true after a fetch with `--prune` removes the stale remote-tracking ref. + */ +@ApiStatus.Internal +fun GitRepositoryState.isUpstreamGone(branch: GitStandardLocalBranch): Boolean = branch in upstreamGoneBranches diff --git a/plugins/git4idea/shared/src/com/intellij/vcs/git/rpc/GitRepositoryApi.kt b/plugins/git4idea/shared/src/com/intellij/vcs/git/rpc/GitRepositoryApi.kt index 5e509e097ca32..03d3261d0ecf9 100644 --- a/plugins/git4idea/shared/src/com/intellij/vcs/git/rpc/GitRepositoryApi.kt +++ b/plugins/git4idea/shared/src/com/intellij/vcs/git/rpc/GitRepositoryApi.kt @@ -146,4 +146,8 @@ class GitRepositoryStateDto( * Maps short names of local branches to their upstream branches. */ val trackingInfo: Map, + /** + * Local branches whose configured upstream no longer exists (git's `gone` state). + */ + val upstreamGoneBranches: Set, ) \ No newline at end of file diff --git a/plugins/git4idea/shared/src/com/intellij/vcs/git/ui/GitBranchesTreeIconProvider.kt b/plugins/git4idea/shared/src/com/intellij/vcs/git/ui/GitBranchesTreeIconProvider.kt index 53069c7dac34b..38574206864a9 100644 --- a/plugins/git4idea/shared/src/com/intellij/vcs/git/ui/GitBranchesTreeIconProvider.kt +++ b/plugins/git4idea/shared/src/com/intellij/vcs/git/ui/GitBranchesTreeIconProvider.kt @@ -11,12 +11,13 @@ import javax.swing.Icon @ApiStatus.Internal object GitBranchesTreeIconProvider { - fun forRef(gitReference: GitReference, current: Boolean, favorite: Boolean, selected: Boolean, favoriteToggleOnClick: Boolean = false): Icon = when { + fun forRef(gitReference: GitReference, current: Boolean, favorite: Boolean, selected: Boolean, favoriteToggleOnClick: Boolean = false, upstreamGone: Boolean = false): Icon = when { selected && !favorite && favoriteToggleOnClick -> AllIcons.Nodes.NotFavoriteOnHover current && favorite -> DvcsImplIcons.CurrentBranchFavoriteLabel current -> DvcsImplIcons.CurrentBranchLabel favorite -> AllIcons.Nodes.Favorite gitReference is GitTag -> DvcsImplIcons.BranchLabel + upstreamGone -> AllIcons.General.Warning else -> AllIcons.Vcs.BranchNode } diff --git a/plugins/git4idea/tests/git4idea/branch/GitOrphanedBranchIconTest.kt b/plugins/git4idea/tests/git4idea/branch/GitOrphanedBranchIconTest.kt new file mode 100644 index 0000000000000..19df67404baf8 --- /dev/null +++ b/plugins/git4idea/tests/git4idea/branch/GitOrphanedBranchIconTest.kt @@ -0,0 +1,69 @@ +// Copyright 2000-2025 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. +package git4idea.branch + +import com.intellij.icons.AllIcons +import com.intellij.testFramework.LightPlatformTestCase +import com.intellij.vcs.git.ref.GitCurrentRef +import com.intellij.vcs.git.repo.GitHash +import com.intellij.vcs.git.repo.GitOperationState +import com.intellij.vcs.git.repo.GitRepositoryState +import com.intellij.vcs.git.repo.isUpstreamGone +import com.intellij.vcs.git.ui.GitBranchesTreeIconProvider +import git4idea.GitStandardLocalBranch +import git4idea.GitStandardRemoteBranch +import git4idea.GitTag +import git4idea.GitWorkingTree +import icons.DvcsImplIcons + +class GitOrphanedBranchIconTest : LightPlatformTestCase() { + + fun `test orphaned local branch uses the warning icon`() { + val branch = GitStandardLocalBranch("feature") + assertSame(AllIcons.General.Warning, + GitBranchesTreeIconProvider.forRef(branch, current = false, favorite = false, selected = false, upstreamGone = true)) + } + + fun `test current and favorite take priority over the warning`() { + val branch = GitStandardLocalBranch("feature") + assertSame(DvcsImplIcons.CurrentBranchLabel, + GitBranchesTreeIconProvider.forRef(branch, current = true, favorite = false, selected = false, upstreamGone = true)) + assertSame(AllIcons.Nodes.Favorite, + GitBranchesTreeIconProvider.forRef(branch, current = false, favorite = true, selected = false, upstreamGone = true)) + } + + fun `test tag is never shown as orphaned`() { + val tag = GitTag("v1.0") + assertSame(DvcsImplIcons.BranchLabel, + GitBranchesTreeIconProvider.forRef(tag, current = false, favorite = false, selected = false, upstreamGone = true)) + } + + fun `test healthy local branch keeps the regular branch icon`() { + val branch = GitStandardLocalBranch("feature") + assertSame(AllIcons.Vcs.BranchNode, + GitBranchesTreeIconProvider.forRef(branch, current = false, favorite = false, selected = false, upstreamGone = false)) + } + + fun `test isUpstreamGone checks membership in the gone-upstream set`() { + val branch = GitStandardLocalBranch("feature") + val other = GitStandardLocalBranch("other") + + assertTrue(state(upstreamGone = setOf(branch)).isUpstreamGone(branch)) + assertFalse(state(upstreamGone = setOf(other)).isUpstreamGone(branch)) + assertFalse(state(upstreamGone = emptySet()).isUpstreamGone(branch)) + } + + private fun state(upstreamGone: Set): GitRepositoryState = + object : GitRepositoryState { + override val currentRef: GitCurrentRef? = null + override val revision: GitHash? = null + override val localBranches: Set = emptySet() + override val remoteBranches: Set = emptySet() + override val tags: Set = emptySet() + override val workingTrees: Collection = emptyList() + override val recentBranches: List = emptyList() + override val operationState: GitOperationState = GitOperationState.NORMAL + override val upstreamGoneBranches: Set = upstreamGone + override fun getDisplayableBranchText(): String = "" + override fun getTrackingInfo(branch: GitStandardLocalBranch): GitStandardRemoteBranch? = null + } +} diff --git a/plugins/git4idea/tests/git4idea/branch/GitOrphanedBranchWidgetTest.kt b/plugins/git4idea/tests/git4idea/branch/GitOrphanedBranchWidgetTest.kt new file mode 100644 index 0000000000000..4c26ea028ec39 --- /dev/null +++ b/plugins/git4idea/tests/git4idea/branch/GitOrphanedBranchWidgetTest.kt @@ -0,0 +1,89 @@ +// Copyright 2000-2025 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. +package git4idea.branch + +import com.intellij.dvcs.repo.repositoryId +import com.intellij.icons.AllIcons +import com.intellij.openapi.application.invokeAndWaitIfNeeded +import com.intellij.openapi.vcs.Executor.cd +import com.intellij.ui.tree.TreeTestUtil +import com.intellij.ui.treeStructure.Tree +import com.intellij.util.ui.tree.TreeUtil +import com.intellij.vcs.git.branch.popup.GitBranchesPopupStepBase +import com.intellij.vcs.git.branch.popup.GitDefaultBranchesPopupStep +import com.intellij.vcs.git.branch.popup.GitDefaultBranchesTreeRenderer +import com.intellij.vcs.git.branch.tree.GitBranchesTreeRenderer +import com.intellij.vcs.git.repo.GitRepositoriesHolder +import git4idea.repo.GitRepository +import git4idea.test.GitPlatformTest +import git4idea.test.branch +import git4idea.test.git +import kotlinx.coroutines.runBlocking +import javax.swing.Icon + +/** + * End-to-end check that an orphaned local branch (configured upstream deleted on the remote, i.e. git's `gone` state) + * is rendered with the warning icon by the real branches-popup tree, exercising the whole pipeline: + * git config -> [git4idea.repo.GitConfig.parseUpstreamGoneBranches] -> repo info -> DTO -> shared state -> renderer. + */ +class GitOrphanedBranchWidgetTest : GitPlatformTest() { + private lateinit var repo: GitRepository + private lateinit var popupStep: GitBranchesPopupStepBase + + override fun setUp() { + super.setUp() + repo = setupRepositories(projectPath, "parent", "bro-repo").projectRepo + cd(projectPath) + refresh() + repositoryManager.updateAllRepositories() + runBlocking { GitRepositoriesHolder.getInstance(project).awaitInitialization() } + } + + fun testOrphanedBranchUsesWarningIcon() { + // A healthy branch that still tracks an existing remote branch. + repo.branch("healthy") + repo.git("push -u origin healthy") + + // An orphaned branch: push & set upstream, then delete it on the remote and prune locally. + // The branch..merge/remote config survives, but refs/remotes/origin/gone-feature is removed. + repo.branch("gone-feature") + repo.git("push -u origin gone-feature") + repo.git("push origin --delete gone-feature") + repo.git("fetch --prune origin") + + repo.update() + repositoryManager.updateAllRepositories() + runBlocking { GitRepositoriesHolder.getInstance(project).awaitInitialization() } + + val icons = iconsByText(buildTestTree()) + + assertEquals("Orphaned branch must use the warning icon", AllIcons.General.Warning, icons["gone-feature"]) + assertEquals("Healthy tracking branch must keep the regular branch icon", AllIcons.Vcs.BranchNode, icons["healthy"]) + } + + private fun iconsByText(tree: Tree): Map = invokeAndWaitIfNeeded { + val renderer = tree.cellRenderer as GitBranchesTreeRenderer + val result = LinkedHashMap() + for (row in 0 until tree.rowCount) { + val userObject = TreeUtil.getUserObject(tree.getPathForRow(row).lastPathComponent) + val text = popupStep.getNodeText(userObject) ?: continue + result[text] = renderer.getIcon(userObject, false) + } + result + } + + private fun buildTestTree(filter: String? = null): Tree { + repositoryManager.updateAllRepositories() + return invokeAndWaitIfNeeded { + val holder = GitRepositoriesHolder.getInstance(project) + val repositories = holder.getAll() + val tree = Tree() + val preferredSelection = checkNotNull(holder.get(repo.repositoryId())) + popupStep = GitDefaultBranchesPopupStep.create(project, preferredSelection, repositories) + tree.cellRenderer = GitDefaultBranchesTreeRenderer(popupStep) + tree.model = popupStep.treeModel + popupStep.updateTreeModelIfNeeded(tree, filter) + popupStep.setSearchPattern(filter) + tree.also { TreeTestUtil(it).expandAll() } + } + } +} diff --git a/plugins/git4idea/tests/git4idea/test/MockGitRepositoryModel.kt b/plugins/git4idea/tests/git4idea/test/MockGitRepositoryModel.kt index e03e26a5e2227..d09ed02f13a99 100644 --- a/plugins/git4idea/tests/git4idea/test/MockGitRepositoryModel.kt +++ b/plugins/git4idea/tests/git4idea/test/MockGitRepositoryModel.kt @@ -33,6 +33,7 @@ internal class MockGitRepositoryModel(repo: GitRepository) : GitRepositoryModel override val revision: @NlsSafe GitHash? = repo.currentRevision?.let { GitHash(it) } override val localBranches: Set = repo.info.localBranchesWithHashes.keys override val remoteBranches: Set = repo.info.remoteBranchesWithHashes.keys.filterIsInstance().toSet() + override val upstreamGoneBranches: Set = repo.info.upstreamGoneBranches.filterIsInstance().toSet() override val tags: Set get() = throw UnsupportedOperationException() override val recentBranches: List = repo.branches.recentCheckoutBranches