diff --git a/plugins/git4idea/backend/api-dump-unreviewed.txt b/plugins/git4idea/backend/api-dump-unreviewed.txt index 506ea2673af9..864f33c6c89f 100644 --- a/plugins/git4idea/backend/api-dump-unreviewed.txt +++ b/plugins/git4idea/backend/api-dump-unreviewed.txt @@ -3952,7 +3952,6 @@ a:git4idea.remote.GitRepositoryHostingService git4idea.remote.InteractiveGitHttpAuthDataProvider - a:getAuthData(java.awt.Component):com.intellij.util.AuthData f:git4idea.remote.hosting.GitAsyncExtensionsKt -- sf:currentRemoteBranchFlow(git4idea.repo.GitRepository):kotlinx.coroutines.flow.Flow - sf:discoverServers(kotlinx.coroutines.flow.Flow,kotlinx.coroutines.flow.Flow,I,kotlin.jvm.functions.Function2):kotlinx.coroutines.flow.Flow - bs:discoverServers$default(kotlinx.coroutines.flow.Flow,kotlinx.coroutines.flow.Flow,I,kotlin.jvm.functions.Function2,I,java.lang.Object):kotlinx.coroutines.flow.Flow - sf:gitRemotesStateIn(com.intellij.openapi.project.Project,kotlinx.coroutines.CoroutineScope,kotlinx.coroutines.flow.SharingStarted):kotlinx.coroutines.flow.StateFlow diff --git a/plugins/git4idea/backend/src/remote/hosting/HostedGitRepositoriesManager.kt b/plugins/git4idea/backend/src/remote/hosting/HostedGitRepositoriesManager.kt index 9e511863f13b..1f0fbec0cb83 100644 --- a/plugins/git4idea/backend/src/remote/hosting/HostedGitRepositoriesManager.kt +++ b/plugins/git4idea/backend/src/remote/hosting/HostedGitRepositoriesManager.kt @@ -6,6 +6,8 @@ import git4idea.repo.GitRepository import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.map interface HostedGitRepositoriesManager { val knownRepositoriesState: StateFlow> @@ -19,9 +21,24 @@ fun HostedGitRepositoriesManager.findKnownRe it.remote.repository == repository } +/** + * Emits the hosted repository mapping and the remote branch tracked by the current local branch (the branch whose + * pull/merge request the current branch is reviewed against), or `null` when the current branch tracks no hosted branch. + * + * Re-emits not only when the tracked branch changes, but also when its tip commit moves (e.g. after a push): review + * features rely on this to re-resolve "which PR/MR corresponds to the current branch" without requiring a branch switch. + * [GitRemoteBranch] equality ignores the commit it points to, so the tip hash is folded into the dedup key explicitly. + */ fun HostedGitRepositoriesManager.findHostedRemoteBranchTrackedByCurrent(repository: GitRepository) : Flow?> = - knownRepositoriesState.combine(repository.currentRemoteBranchFlow()) { repositories, branch -> + knownRepositoriesState.combine( + repository.infoFlow() + .map { info -> + val branch = info.findFirstRemoteBranchTrackedByCurrent() + branch to branch?.let { info.remoteBranchesWithHashes[it] } + } + .distinctUntilChanged() + ) { repositories, (branch, _) -> if (branch == null) { null } diff --git a/plugins/git4idea/backend/src/remote/hosting/gitAsyncExtensions.kt b/plugins/git4idea/backend/src/remote/hosting/gitAsyncExtensions.kt index d40762672c5e..47f5cd003f8e 100644 --- a/plugins/git4idea/backend/src/remote/hosting/gitAsyncExtensions.kt +++ b/plugins/git4idea/backend/src/remote/hosting/gitAsyncExtensions.kt @@ -89,11 +89,6 @@ private fun GitRepositoryManager.collectRemotes(): Set }.toSet() } -fun GitRepository.currentRemoteBranchFlow(): Flow = - infoFlow() - .map { it.findFirstRemoteBranchTrackedByCurrent() } - .distinctUntilChanged() - fun GitRepoInfo.findFirstRemoteBranchTrackedByCurrent(): GitRemoteBranch? { val currentBranch = currentBranch ?: return null return branchTrackInfos.find { it.localBranch == currentBranch }?.remoteBranch diff --git a/plugins/git4idea/tests/git4idea/remote/hosting/CurrentReviewBranchFlowTest.kt b/plugins/git4idea/tests/git4idea/remote/hosting/CurrentReviewBranchFlowTest.kt new file mode 100644 index 000000000000..966ff4d3b2f1 --- /dev/null +++ b/plugins/git4idea/tests/git4idea/remote/hosting/CurrentReviewBranchFlowTest.kt @@ -0,0 +1,69 @@ +// Copyright 2000-2026 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. +package git4idea.remote.hosting + +import com.intellij.openapi.vcs.Executor.cd +import com.intellij.testFramework.common.waitUntil +import git4idea.GitRemoteBranch +import git4idea.push.GitPushOperationBaseTest +import git4idea.remote.GitRemoteUrlCoordinates +import git4idea.repo.GitRepository +import git4idea.test.git +import git4idea.test.makeCommit +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import org.mockito.Mockito.mock +import java.util.concurrent.CopyOnWriteArrayList +import kotlin.time.Duration.Companion.seconds + +class CurrentReviewBranchFlowTest : GitPushOperationBaseTest() { + + private lateinit var repository: GitRepository + private lateinit var manager: HostedGitRepositoriesManager + + override fun setUp() { + super.setUp() + + val trinity = setupRepositories(projectPath, "parent", "bro") + repository = trinity.projectRepo + cd(projectPath) + refresh() + updateRepositories() + + // A single hosted mapping for the "origin" remote of the test repository. + val origin = repository.remotes.single { it.name == "origin" } + val mapping = object : HostedGitRepositoryMapping { + override val repository: HostedRepositoryCoordinates = mock(HostedRepositoryCoordinates::class.java) + override val remote: GitRemoteUrlCoordinates = GitRemoteUrlCoordinates(origin.firstUrl!!, origin, this@CurrentReviewBranchFlowTest.repository) + } + manager = object : HostedGitRepositoriesManager { + override val knownRepositoriesState = MutableStateFlow(setOf(mapping)) + } + } + + fun `test re-emits the review branch when the current branch tip moves on push`() = runBlocking { + val emissions = CopyOnWriteArrayList?>() + val job = launch(Dispatchers.Default) { + manager.findHostedRemoteBranchTrackedByCurrent(repository).collect { emissions.add(it) } + } + try { + // Initial resolution: the current branch (master) tracks origin/master. + waitUntil("initial review branch is resolved", timeout = 10.seconds) { emissions.isNotEmpty() } + assertEquals("master", emissions.last()?.second?.nameForRemoteOperations) + val countBeforePush = emissions.size + + // A push advances origin/master's tip while the tracked branch identity stays the same. + makeCommit("file.txt") + repository.git("push origin master") + updateRepositories() + + // The moved tip must produce a fresh emission (this is what the fix restores). + waitUntil("review branch re-emitted after push", timeout = 10.seconds) { emissions.size > countBeforePush } + assertEquals("master", emissions.last()?.second?.nameForRemoteOperations) + } + finally { + job.cancel() + } + } +} diff --git a/plugins/github/github-core/src/org/jetbrains/plugins/github/pullrequest/data/GHPRListLoader.kt b/plugins/github/github-core/src/org/jetbrains/plugins/github/pullrequest/data/GHPRListLoader.kt index fee34adbd709..b811e31efc9c 100644 --- a/plugins/github/github-core/src/org/jetbrains/plugins/github/pullrequest/data/GHPRListLoader.kt +++ b/plugins/github/github-core/src/org/jetbrains/plugins/github/pullrequest/data/GHPRListLoader.kt @@ -12,8 +12,10 @@ import com.intellij.collaboration.util.SingleCoroutineLauncher import com.intellij.platform.util.coroutines.childScope import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.map import org.jetbrains.plugins.github.api.GHGQLRequests import org.jetbrains.plugins.github.api.GHRepositoryCoordinates @@ -42,6 +44,10 @@ internal class GHPRListLoader( private val refreshRequests = MutableSharedFlow(replay = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST) private val updateRequests = MutableSharedFlow>(replay = 1) + private val listUpdatedRequests = MutableSharedFlow(extraBufferCapacity = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST) + + val listUpdated: Flow = listUpdatedRequests.asSharedFlow() + private val loader = GraphQLListLoader.startIn( cs, { it.id }, @@ -76,12 +82,14 @@ internal class GHPRListLoader( fun refresh() { refreshRequests.tryEmit(Unit) + listUpdatedRequests.tryEmit(Unit) } fun reload() { cs.launchNow { reloadRequests.emit(Unit) } + listUpdatedRequests.tryEmit(Unit) } companion object { diff --git a/plugins/github/github-core/src/org/jetbrains/plugins/github/pullrequest/ui/GHPRConnectedProjectViewModel.kt b/plugins/github/github-core/src/org/jetbrains/plugins/github/pullrequest/ui/GHPRConnectedProjectViewModel.kt index b1c0528165ec..d6b6b8de9b94 100644 --- a/plugins/github/github-core/src/org/jetbrains/plugins/github/pullrequest/ui/GHPRConnectedProjectViewModel.kt +++ b/plugins/github/github-core/src/org/jetbrains/plugins/github/pullrequest/ui/GHPRConnectedProjectViewModel.kt @@ -17,6 +17,7 @@ import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.combineTransform +import kotlinx.coroutines.flow.merge import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import org.jetbrains.annotations.ApiStatus @@ -118,9 +119,13 @@ abstract class GHPRConnectedProjectViewModelBase( private val prOnCurrentBranchRefreshSignal = MutableSharedFlow(extraBufferCapacity = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST) + // Re-run the lookup on an explicit refresh and whenever the PR list is reloaded/refreshed, so a PR opened outside + // the IDE (or otherwise missed by the initial lookup) is picked up on the next refresh without switching branches. + private val prOnCurrentBranchRefresh = merge(prOnCurrentBranchRefreshSignal, dataContext.listLoader.listUpdated) + override val prOnCurrentBranch: StateFlow?> = repoManager.findHostedRemoteBranchTrackedByCurrent(connection.repo.gitRepository) - .combineTransform(prOnCurrentBranchRefreshSignal.withInitial(Unit)) { projectAndBranch, _ -> + .combineTransform(prOnCurrentBranchRefresh.withInitial(Unit)) { projectAndBranch, _ -> if (projectAndBranch == null) { emit(ComputedResult.success(null)) } diff --git a/plugins/gitlab/gitlab-core/src/org/jetbrains/plugins/gitlab/mergerequest/ui/GitLabConnectedProjectViewModel.kt b/plugins/gitlab/gitlab-core/src/org/jetbrains/plugins/gitlab/mergerequest/ui/GitLabConnectedProjectViewModel.kt index d5dd8514ed85..3039bf3af1dd 100644 --- a/plugins/gitlab/gitlab-core/src/org/jetbrains/plugins/gitlab/mergerequest/ui/GitLabConnectedProjectViewModel.kt +++ b/plugins/gitlab/gitlab-core/src/org/jetbrains/plugins/gitlab/mergerequest/ui/GitLabConnectedProjectViewModel.kt @@ -27,6 +27,7 @@ import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.merge import kotlinx.coroutines.flow.shareIn import kotlinx.coroutines.launch import org.jetbrains.annotations.ApiStatus @@ -126,9 +127,13 @@ abstract class GitLabConnectedProjectViewModelBase( protected val mergeRequestCreatedSignal: MutableSharedFlow = MutableSharedFlow() + // Re-run the lookup on MR creation and whenever the MR list is reloaded/refreshed, so an MR opened outside the IDE + // (or otherwise missed by the initial lookup) is picked up on the next refresh without switching branches. + private val mergeRequestOnCurrentBranchRefresh = merge(mergeRequestCreatedSignal, listVm.listUpdated) + private val mergeRequestOnCurrentBranch: Flow = projectsManager.findHostedRemoteBranchTrackedByCurrent(connection.repo.gitRepository) - .combine(mergeRequestCreatedSignal.withInitial(Unit)) { repoAndBranch, _ -> + .combine(mergeRequestOnCurrentBranchRefresh.withInitial(Unit)) { repoAndBranch, _ -> val (targetRepo, branch) = repoAndBranch ?: return@combine null try { findOpenReviewIdByBranch(connection, branch.nameForRemoteOperations, targetRepo.repository.projectPath.fullPath()) diff --git a/plugins/gitlab/gitlab-core/src/org/jetbrains/plugins/gitlab/mergerequest/ui/list/GitLabMergeRequestsListViewModel.kt b/plugins/gitlab/gitlab-core/src/org/jetbrains/plugins/gitlab/mergerequest/ui/list/GitLabMergeRequestsListViewModel.kt index 4fed4f511b9b..0cff13042350 100644 --- a/plugins/gitlab/gitlab-core/src/org/jetbrains/plugins/gitlab/mergerequest/ui/list/GitLabMergeRequestsListViewModel.kt +++ b/plugins/gitlab/gitlab-core/src/org/jetbrains/plugins/gitlab/mergerequest/ui/list/GitLabMergeRequestsListViewModel.kt @@ -12,8 +12,11 @@ import com.intellij.openapi.diagnostic.Logger import com.intellij.platform.util.coroutines.childScope import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.channels.BufferOverflow import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.flatMapLatest @@ -40,6 +43,12 @@ interface GitLabMergeRequestsListViewModel : ReviewListViewModel { val loading: Flow val error: Flow + /** + * Emits whenever the list is reloaded or refreshed. Consumers can use this to re-run branch-scoped lookups + * (e.g. "which MR corresponds to the current branch") when the user refreshes the merge request list. + */ + val listUpdated: Flow + fun requestMore() } @@ -56,6 +65,9 @@ internal class GitLabMergeRequestsListViewModelImpl( private val scope = parentCs.childScope("GL MR List VM") private val requestMoreLauncher = SingleCoroutineLauncher(scope.childScope("Request More")) + private val listUpdatedSignal = MutableSharedFlow(extraBufferCapacity = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST) + override val listUpdated: Flow = listUpdatedSignal.asSharedFlow() + private val loaderFlow: Flow> = filterVm.searchState .combine(tokenRefreshFlow.withInitial(Unit)) { search, _ -> search } @@ -75,12 +87,14 @@ internal class GitLabMergeRequestsListViewModelImpl( } override fun refresh() { + listUpdatedSignal.tryEmit(Unit) scope.launch { loaderFlow.first().refresh() } } override fun reload() { + listUpdatedSignal.tryEmit(Unit) scope.launch { loaderFlow.first().reload() }