From 2165d3dec5799fc65942305c162992cb26d10aad Mon Sep 17 00:00:00 2001 From: Tsanko Tsolov Date: Fri, 26 Jun 2026 13:42:00 -0700 Subject: [PATCH] show a warning icon for local branches whose upstream was deleted When a local branch tracks a remote branch that has since been deleted (git's `gone` state, e.g. after `fetch --prune` removes the stale remote-tracking ref), nothing in the branches tree signalled that the branch is now orphaned. This surfaces it with a warning icon and a tooltip note so the branch can be noticed and cleaned up. Such branches keep their `branch..merge`/`remote` config but have no resolved GitBranchTrackInfo, so they can't be detected from tracking info. They are found by parsing the raw git config (GitConfig.parseUpstreamGoneBranches) and checking whether the configured upstream is still present among the known remote branches. The result is plumbed from the backend repo info through the RPC DTO into the shared GitRepositoryState (upstreamGoneBranches) so the frontend renderer can pick the warning icon, with current/favorite states still taking priority over it. Tags and refs that can't track an upstream are never marked gone. --- .../remoteApi/GitRepositoryToDtoConverter.kt | 4 +- .../git4idea/backend/src/repo/GitConfig.kt | 27 ++++++ .../git4idea/backend/src/repo/GitRepoInfo.kt | 4 +- .../backend/src/repo/GitRepositoryImpl.kt | 4 +- .../src/ui/branch/dashboard/BranchesTree.kt | 20 ++++- .../ui/branch/dashboard/BranchesTreeModel.kt | 8 ++ .../resources/messages/GitBundle.properties | 1 + .../branch/tree/GitBranchesTreeRenderer.kt | 7 +- .../vcs/git/repo/GitRepositoriesHolder.kt | 2 + .../vcs/git/repo/GitRepositoryState.kt | 13 +++ .../intellij/vcs/git/rpc/GitRepositoryApi.kt | 4 + .../vcs/git/ui/GitBranchesTreeIconProvider.kt | 3 +- .../branch/GitOrphanedBranchIconTest.kt | 69 ++++++++++++++ .../branch/GitOrphanedBranchWidgetTest.kt | 89 +++++++++++++++++++ .../git4idea/test/MockGitRepositoryModel.kt | 1 + 15 files changed, 249 insertions(+), 7 deletions(-) create mode 100644 plugins/git4idea/tests/git4idea/branch/GitOrphanedBranchIconTest.kt create mode 100644 plugins/git4idea/tests/git4idea/branch/GitOrphanedBranchWidgetTest.kt 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