Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<GitStandardLocalBranch>().toSet(),
)
}

Expand Down
27 changes: 27 additions & 0 deletions plugins/git4idea/backend/src/repo/GitConfig.kt
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,20 @@ class GitConfig private constructor(
convertBranchConfig(config, localBranches, remoteBranches)
}.toSet()

/**
* Returns local branches that have a configured upstream (`branch.<name>.merge` + `branch.<name>.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<GitLocalBranch>,
remoteBranches: Collection<GitRemoteBranch>,
): Set<GitLocalBranch> =
trackedInfos.mapNotNullTo(HashSet()) { config ->
if (isUpstreamConfiguredButGone(config, remoteBranches)) findLocalBranch(config.name, localBranches) else null
}

/**
* Return core info
*/
Expand Down Expand Up @@ -206,6 +220,19 @@ class GitConfig private constructor(
return GitBranchTrackInfo(localBranch, remoteBranch, merge)
}

private fun isUpstreamConfiguredButGone(branchConfig: BranchConfig, remoteBranches: Collection<GitRemoteBranch>): 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>): GitLocalBranch? {
val name = GitBranchUtil.stripRefsPrefix(branchName)
return localBranches.find { input -> input.name == name }
Expand Down
4 changes: 3 additions & 1 deletion plugins/git4idea/backend/src/repo/GitRepoInfo.kt
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,9 @@ data class GitRepoInfo(val currentBranch: GitLocalBranch?,
val branchTrackInfos: Collection<GitBranchTrackInfo>,
val submodules: Collection<GitSubmoduleInfo>,
val hooksInfo: GitHooksInfo,
val isShallow: Boolean) {
val isShallow: Boolean,
/** Local branches whose configured upstream no longer exists (git's `gone` state). */
val upstreamGoneBranches: Set<GitLocalBranch> = emptySet()) {
val branchTrackInfosMap: Map<String, GitBranchTrackInfo> =
branchTrackInfos.associateByTo(CollectionFactory.createCustomHashingStrategyMap(GitReference.BRANCH_NAME_HASHING_STRATEGY)) { it.localBranch.name }

Expand Down
4 changes: 3 additions & 1 deletion plugins/git4idea/backend/src/repo/GitRepositoryImpl.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand All @@ -223,7 +224,8 @@ class GitRepositoryImpl private constructor(
branchTrackInfos = trackInfos,
submodules = submodules,
hooksInfo = hooksInfo,
isShallow = isShallow)
isShallow = isShallow,
upstreamGoneBranches = upstreamGoneBranches)
}
}

Expand Down
20 changes: 18 additions & 2 deletions plugins/git4idea/backend/src/ui/branch/dashboard/BranchesTree.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 ->
Expand All @@ -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
Expand Down Expand Up @@ -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 `<tr>` rows for multiple repos
val body = if (trackingTooltip.contains("<tr")) "<table>$trackingTooltip</table>" else XmlStringUtil.escapeString(trackingTooltip)
return XmlStringUtil.wrapInHtml("$body<br>$note")
}

override fun paint(g: Graphics) {
super.paint(g)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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? {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -223,6 +224,7 @@ private class GitRepositoryStateImpl(
override val recentBranches: List<GitStandardLocalBranch>,
override val operationState: GitOperationState,
private val trackingInfo: Map<String, GitStandardRemoteBranch>,
override val upstreamGoneBranches: Set<GitStandardLocalBranch>,
) : GitRepositoryState {
override fun getTrackingInfo(branch: GitStandardLocalBranch): GitStandardRemoteBranch? = trackingInfo[branch.name]

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,10 @@ interface GitRepositoryState {
val recentBranches: List<GitStandardLocalBranch>
val operationState: GitOperationState

/** Local branches whose configured upstream no longer exists (git's `gone` state). See [isUpstreamGone]. */
val upstreamGoneBranches: Set<GitStandardLocalBranch>
get() = emptySet()

val currentBranch: GitStandardLocalBranch? get() = (currentRef as? GitCurrentRef.LocalBranch)?.branch

/**
Expand All @@ -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
Original file line number Diff line number Diff line change
Expand Up @@ -146,4 +146,8 @@ class GitRepositoryStateDto(
* Maps short names of local branches to their upstream branches.
*/
val trackingInfo: Map<String, GitStandardRemoteBranch>,
/**
* Local branches whose configured upstream no longer exists (git's `gone` state).
*/
val upstreamGoneBranches: Set<GitStandardLocalBranch>,
)
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down
Original file line number Diff line number Diff line change
@@ -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<GitStandardLocalBranch>): GitRepositoryState =
object : GitRepositoryState {
override val currentRef: GitCurrentRef? = null
override val revision: GitHash? = null
override val localBranches: Set<GitStandardLocalBranch> = emptySet()
override val remoteBranches: Set<GitStandardRemoteBranch> = emptySet()
override val tags: Set<GitTag> = emptySet()
override val workingTrees: Collection<GitWorkingTree> = emptyList()
override val recentBranches: List<GitStandardLocalBranch> = emptyList()
override val operationState: GitOperationState = GitOperationState.NORMAL
override val upstreamGoneBranches: Set<GitStandardLocalBranch> = upstreamGone
override fun getDisplayableBranchText(): String = ""
override fun getTrackingInfo(branch: GitStandardLocalBranch): GitStandardRemoteBranch? = null
}
}
Loading