diff --git a/platform/dvcs-impl/api-dump.txt b/platform/dvcs-impl/api-dump.txt index 0ae36519b623..2319df64d90b 100644 --- a/platform/dvcs-impl/api-dump.txt +++ b/platform/dvcs-impl/api-dump.txt @@ -242,10 +242,10 @@ f:com.intellij.dvcs.ignore.IgnoredToExcludedSynchronizer - doActionOnChosenFiles(java.util.Collection):V - doFilterFiles(java.util.Collection):java.util.List - f:getValidFiles():java.util.List -- f:ignoredUpdateFinished(java.util.Collection):V - f:isNotEmpty():Z - f:muteForCurrentProject():V - f:mutedForCurrentProject():Z +- f:onIgnoredFilesUpdate(java.util.Set,java.util.Set):V a:com.intellij.dvcs.ignore.VcsIgnoredFilesHolderBase - com.intellij.dvcs.repo.VcsManagedFilesHolderBase - (com.intellij.dvcs.repo.AbstractRepositoryManager):V diff --git a/platform/dvcs-impl/src/com/intellij/dvcs/ignore/IgnoredToExcludedSynchronizer.kt b/platform/dvcs-impl/src/com/intellij/dvcs/ignore/IgnoredToExcludedSynchronizer.kt index 5ba439f6f2ba..ae40229478dc 100644 --- a/platform/dvcs-impl/src/com/intellij/dvcs/ignore/IgnoredToExcludedSynchronizer.kt +++ b/platform/dvcs-impl/src/com/intellij/dvcs/ignore/IgnoredToExcludedSynchronizer.kt @@ -136,7 +136,14 @@ class IgnoredToExcludedSynchronizer(project: Project, cs: CoroutineScope) : File override fun needDoForCurrentProject() = VcsConfiguration.getInstance(project).MARK_IGNORED_AS_EXCLUDED - fun ignoredUpdateFinished(ignoredPaths: Collection) { + fun onIgnoredFilesUpdate(ignoredFilePaths: Set, previouslyIgnoredFilePaths: Set) { + val addedIgnored = ignoredFilePaths.filter { it !in previouslyIgnoredFilePaths } + if (!addedIgnored.isEmpty()) { + ignoredUpdateFinished(addedIgnored) + } + } + + private fun ignoredUpdateFinished(ignoredPaths: Collection) { ProgressManager.checkCanceled() if (synchronizationTurnOff()) return if (mutedForCurrentProject()) return @@ -294,7 +301,7 @@ internal class CheckIgnoredToExcludeAction : DumbAwareAction() { // do not use SelectFilesDialog.init because it doesn't provide clear statistic: what exactly dialog shown/closed, action clicked private class IgnoredToExcludeSelectDirectoriesDialog( project: Project?, - files: List + files: List, ) : SelectFilesDialog(project, files, message("ignore.to.exclude.notification.notice"), null, true, true) { init { title = message("ignore.to.exclude.view.dialog.title") diff --git a/plugins/git4idea/resources/META-INF/plugin.xml b/plugins/git4idea/resources/META-INF/plugin.xml index 07b092b27bf2..946972ec5f99 100644 --- a/plugins/git4idea/resources/META-INF/plugin.xml +++ b/plugins/git4idea/resources/META-INF/plugin.xml @@ -862,6 +862,8 @@ + + diff --git a/plugins/git4idea/resources/messages/GitBundle.properties b/plugins/git4idea/resources/messages/GitBundle.properties index 68a10463fb82..d8c5e89b5442 100644 --- a/plugins/git4idea/resources/messages/GitBundle.properties +++ b/plugins/git4idea/resources/messages/GitBundle.properties @@ -1535,6 +1535,8 @@ search.everywhere.items.tag=Tag search.everywhere.items.commit.by.message=Commit by message commit.author.with.committer={0}, via {1} +search.scope.project.git.exclude.ignored=Project Files Excluding Git-ignored + branch.direction.panel.branch.label=Branch: branch.direction.panel.save.button=Save branch.direction.panel.base.repo.label=Base repository: diff --git a/plugins/git4idea/src/git4idea/ignore/GitRepositoryIgnoredFilesHolder.kt b/plugins/git4idea/src/git4idea/ignore/GitRepositoryIgnoredFilesHolder.kt index 542c2ca2e3ee..b5e8e567a92b 100644 --- a/plugins/git4idea/src/git4idea/ignore/GitRepositoryIgnoredFilesHolder.kt +++ b/plugins/git4idea/src/git4idea/ignore/GitRepositoryIgnoredFilesHolder.kt @@ -9,7 +9,7 @@ import org.jetbrains.annotations.TestOnly class GitRepositoryIgnoredFilesHolder(private val repository: GitRepository) { fun isInUpdateMode(): Boolean = repository.untrackedFilesHolder.isInUpdateMode fun containsFile(file: FilePath): Boolean = repository.untrackedFilesHolder.containsIgnoredFile(file) - val ignoredFilePaths: Set get() = repository.untrackedFilesHolder.ignoredFilePaths.toSet() + val ignoredFilePaths: Set get() = repository.untrackedFilesHolder.ignoredFilePaths fun retrieveIgnoredFilePaths(): Collection = repository.untrackedFilesHolder.retrieveIgnoredFilePaths() fun removeIgnoredFiles(filePaths: Collection) { diff --git a/plugins/git4idea/src/git4idea/repo/GitUntrackedFilesHolder.java b/plugins/git4idea/src/git4idea/repo/GitUntrackedFilesHolder.java index 4f2d08cfa4a5..f92950a2ae27 100644 --- a/plugins/git4idea/src/git4idea/repo/GitUntrackedFilesHolder.java +++ b/plugins/git4idea/src/git4idea/repo/GitUntrackedFilesHolder.java @@ -168,15 +168,15 @@ public class GitUntrackedFilesHolder implements Disposable { } } - public @NotNull Collection getUntrackedFilePaths() { + public @NotNull Set getUntrackedFilePaths() { synchronized (LOCK) { - return new ArrayList<>(myUntrackedFiles); + return new HashSet<>(myUntrackedFiles); } } - public @NotNull Collection getIgnoredFilePaths() { + public @NotNull Set getIgnoredFilePaths() { synchronized (LOCK) { - return new ArrayList<>(myIgnoredFiles.filePaths()); + return new HashSet<>(myIgnoredFiles.filePaths()); } } @@ -253,11 +253,11 @@ public class GitUntrackedFilesHolder implements Disposable { } Set oldIgnored; - List newIgnored; + Set newIgnored; synchronized (LOCK) { - oldIgnored = new HashSet<>(myIgnoredFiles.filePaths()); + oldIgnored = getIgnoredFilePaths(); applyRefreshResult(result, dirtyScope, oldIgnored); - newIgnored = new ArrayList<>(myIgnoredFiles.filePaths()); + newIgnored = getIgnoredFilePaths(); myInUpdate = isDirty(); } @@ -265,7 +265,8 @@ public class GitUntrackedFilesHolder implements Disposable { BackgroundTaskUtil.syncPublisher(myProject, GitRefreshListener.TOPIC).repositoryUpdated(myRepository); BackgroundTaskUtil.syncPublisher(myProject, VcsManagedFilesHolder.TOPIC).updatingModeChanged(); ChangeListManagerImpl.getInstanceImpl(myProject).notifyUnchangedFileStatusChanged(); - notifyExcludedSynchronizer(oldIgnored, newIgnored); + + myProject.getService(IgnoredToExcludedSynchronizer.class).onIgnoredFilesUpdate(newIgnored, oldIgnored); } finally { BackgroundTaskUtil.syncPublisher(myProject, GitRefreshListener.TOPIC).progressStopped(); @@ -300,18 +301,6 @@ public class GitUntrackedFilesHolder implements Disposable { } } - private void notifyExcludedSynchronizer(@NotNull Set oldIgnored, @NotNull List newIgnored) { - List addedIgnored = new ArrayList<>(); - for (FilePath filePath : newIgnored) { - if (!oldIgnored.contains(filePath)) { - addedIgnored.add(filePath); - } - } - if (!addedIgnored.isEmpty()) { - myProject.getService(IgnoredToExcludedSynchronizer.class).ignoredUpdateFinished(addedIgnored); - } - } - /** * @see git4idea.status.GitStagingAreaHolder#removeUnwantedRecords */ diff --git a/plugins/git4idea/src/git4idea/search/GitSearchScopeProvider.kt b/plugins/git4idea/src/git4idea/search/GitSearchScopeProvider.kt new file mode 100644 index 000000000000..039154ee2451 --- /dev/null +++ b/plugins/git4idea/src/git4idea/search/GitSearchScopeProvider.kt @@ -0,0 +1,69 @@ +// Copyright 2000-2024 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. +package git4idea.search + +import com.intellij.openapi.actionSystem.DataContext +import com.intellij.openapi.diagnostic.logger +import com.intellij.openapi.project.Project +import com.intellij.openapi.roots.FileIndexFacade +import com.intellij.openapi.vcs.ProjectLevelVcsManager +import com.intellij.openapi.vfs.VirtualFile +import com.intellij.psi.search.ProjectScopeImpl +import com.intellij.psi.search.SearchScope +import com.intellij.psi.search.SearchScopeProvider +import git4idea.i18n.GitBundle +import git4idea.ignore.GitRepositoryIgnoredFilesHolder +import git4idea.index.vfs.filePath +import git4idea.repo.GitRepository +import git4idea.repo.GitRepositoryManager +import org.jetbrains.annotations.Nls +import org.jetbrains.annotations.VisibleForTesting + +private val LOG = logger() + +internal class GitSearchScopeProvider : SearchScopeProvider { + override fun getGeneralSearchScopes(project: Project, dataContext: DataContext): List = + listOfNotNull(GitIgnoreSearchScope.getSearchScope(project)) +} + +internal class GitIgnoreSearchScope( + private val project: Project, + private val rootToIgnoredFiles: Map, +) : ProjectScopeImpl(project, FileIndexFacade.getInstance(project)) { + private val singleIgnoredFilesHolder: GitRepositoryIgnoredFilesHolder? = rootToIgnoredFiles.values.singleOrNull() + + @Nls + override fun getDisplayName(): String = GitBundle.message("search.scope.project.git.exclude.ignored") + + override fun contains(file: VirtualFile): Boolean = super.contains(file) && !isIgnored(file) + + @VisibleForTesting + fun isIgnored(file: VirtualFile): Boolean { + val filePath = file.filePath() + if (singleIgnoredFilesHolder != null) return singleIgnoredFilesHolder.containsFile(filePath) + + val gitRoot = ProjectLevelVcsManager.getInstance(project).getVcsRootFor(file) ?: return false + + return rootToIgnoredFiles[gitRoot]?.containsFile(filePath) == true + } + + companion object { + @VisibleForTesting + internal fun getSearchScope(project: Project): GitIgnoreSearchScope? { + val repositories = GitRepositoryManager.getInstance(project).repositories + if (repositories.isEmpty()) return null + val repoToIgnoredFiles = getIgnoredFilesMapping(repositories) ?: return null + return GitIgnoreSearchScope(project, repoToIgnoredFiles) + } + + private fun getIgnoredFilesMapping(repositories: List): Map? = + repositories.associate { repo -> + if (!repo.ignoredFilesHolder.initialized) { + if (LOG.isDebugEnabled) { + LOG.debug("Ignored files holder is not initialized for $repo") + } + return null + } + repo.root to repo.ignoredFilesHolder + } + } +} \ No newline at end of file diff --git a/plugins/git4idea/tests/git4idea/scope/GitSearchScopeTest.kt b/plugins/git4idea/tests/git4idea/scope/GitSearchScopeTest.kt new file mode 100644 index 000000000000..bd989d275e36 --- /dev/null +++ b/plugins/git4idea/tests/git4idea/scope/GitSearchScopeTest.kt @@ -0,0 +1,163 @@ +// Copyright 2000-2024 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. +package git4idea.scope + +import com.intellij.dvcs.repo.VcsRepositoryManager +import com.intellij.openapi.actionSystem.DataContext +import com.intellij.openapi.application.readAction +import com.intellij.openapi.application.writeAction +import com.intellij.openapi.roots.ModuleRootModificationUtil +import com.intellij.openapi.vcs.changes.VcsIgnoreManagerImpl +import com.intellij.openapi.vfs.VirtualFile +import com.intellij.openapi.vfs.findOrCreateDirectory +import com.intellij.psi.search.SearchScopeProvider +import com.intellij.vfs.AsyncVfsEventsPostProcessorImpl +import git4idea.index.vfs.filePath +import git4idea.repo.GitRepositoryFiles.GITIGNORE +import git4idea.search.GitIgnoreSearchScope +import git4idea.search.GitSearchScopeProvider +import git4idea.test.GitSingleRepoTest +import git4idea.test.createFileStructure +import git4idea.test.createSubRepository +import git4idea.util.GitFileUtils +import kotlinx.coroutines.runBlocking +import java.util.concurrent.TimeUnit + +class GitSearchScopeTest : GitSingleRepoTest() { + fun `test no gitignore`() { + val fileName = "file" + repo.root.createFile(fileName) + getGitIgnoreSearchScope().assertScope(shouldContain = listOf(fileName)) + } + + fun `test ignored files are not in scope`() { + val ignoredFiles = listOf("1/ignore", "1/file", "ignore") + val includedFiles = listOf("file", "2/file", "another-file") + + createFileStructure(repo.root, *ignoredFiles.toTypedArray(), *includedFiles.toTypedArray()) + + gitIgnore("ignore") + gitIgnore(repo.root.findOrCreateDirectory("1"), "file") + + getGitIgnoreSearchScope().assertScope(shouldContain = includedFiles, shouldNotContain = ignoredFiles) + } + + fun `test explicitly added ignored files are in scope`() { + val toBeIgnored = "to-be-ignored" + gitIgnore("**") + val toBeIgnoredFile = repo.root.createFile(toBeIgnored) + getGitIgnoreSearchScope().assertScope(shouldNotContain = listOf(toBeIgnored)) + + GitFileUtils.addPaths(project, repo.root, listOf(toBeIgnoredFile.filePath()), true) + + getGitIgnoreSearchScope().assertScope(shouldContain = listOf(toBeIgnored)) + } + + fun `test gitignore added and deleted`() { + val txtFiles = listOf("1.txt", "2.txt") + val notTxtFiles = listOf("1.png", "2.png") + + createFileStructure(repo.root, *txtFiles.toTypedArray(), *notTxtFiles.toTypedArray()) + val gitIgnore = gitIgnore("*.txt") + getGitIgnoreSearchScope().assertScope(shouldContain = notTxtFiles, shouldNotContain = txtFiles) + + runBlocking { + writeAction { + gitIgnore.delete(this) + } + } + getGitIgnoreSearchScope().assertScope(shouldContain = notTxtFiles + txtFiles) + + gitIgnore("**") + getGitIgnoreSearchScope().assertScope(shouldNotContain = notTxtFiles + txtFiles) + } + + fun `test excluded files are not in scope`() { + val module = createMainModule() + ModuleRootModificationUtil.addContentRoot(module, projectRoot) + ModuleRootModificationUtil.updateExcludedFolders(module, projectRoot, emptyList(), listOf(projectRoot.url + "/excluded")) + + val filesNotInScope = listOf("1.txt", "excluded/file") + createFileStructure(repo.root, *filesNotInScope.toTypedArray()) + gitIgnore("*.txt") + val scope = getGitIgnoreSearchScope() + + assertFalse(scope.isSearchInLibraries) + assertTrue(scope.isSearchInModuleContent(module)) + + for (path in filesNotInScope) { + runBlocking { + readAction { + assertFalse("'$path' should be excluded from the scope", scope.contains(repo.root.findFileByRelativePath(path)!!)) + } + } + } + } + + fun `test nested repo gitignore scope`() { + // Sub repository name is added to repo .gitignore + val nestedRepo = repo.createSubRepository("nested") + + val nestedGitIgnore = gitIgnore(nestedRepo.root, "*.txt") + + val ignoredFiles = listOf("nested/1.txt") + val includedFiles = listOf("1.txt", "nested/file") + + createFileStructure(repo.root, *ignoredFiles.toTypedArray(), *includedFiles.toTypedArray()) + + getGitIgnoreSearchScope().assertScope(shouldContain = includedFiles, shouldNotContain = ignoredFiles) + + runBlocking { + writeAction { + nestedGitIgnore.delete(this) + } + } + + getGitIgnoreSearchScope().assertScope(shouldContain = includedFiles + ignoredFiles) + } + + fun `test nested repo gitignore scope with deeper hierrarchy`() { + // Sub repository name is added to repo .gitignore + val nestedRepo = repo.createSubRepository("deps/subprojects/nested") + + gitIgnore(repo.root, "deps/**") + gitIgnore(nestedRepo.root, "*.txt") + + val ignoredFiles = listOf("deps/subprojects/nested/1.txt") + val includedFiles = listOf("1.txt", "deps/subprojects/nested/file") + + createFileStructure(repo.root, *ignoredFiles.toTypedArray(), *includedFiles.toTypedArray()) + + getGitIgnoreSearchScope().assertScope(shouldContain = includedFiles, shouldNotContain = ignoredFiles) + } + + fun `test no scope is provided if no git repo registered`() { + val scopeProvider = SearchScopeProvider.EP_NAME.extensionList.filterIsInstance().single() + awaitEvents() + assertNotEmpty(scopeProvider.getGeneralSearchScopes(project, DataContext.EMPTY_CONTEXT)) + vcsManager.unregisterVcs(vcs) + VcsRepositoryManager.getInstance(myProject).waitForAsyncTaskCompletion() + assertEmpty(scopeProvider.getGeneralSearchScopes(project, DataContext.EMPTY_CONTEXT)) + } + + private fun getGitIgnoreSearchScope(): GitIgnoreSearchScope { + awaitEvents() + return checkNotNull(GitIgnoreSearchScope.getSearchScope(project)) + } + + private fun awaitEvents() { + AsyncVfsEventsPostProcessorImpl.waitEventsProcessed() + VcsIgnoreManagerImpl.getInstanceImpl(project).ignoreRefreshQueue.waitForAllExecuted(10, TimeUnit.SECONDS) + } + + private fun GitIgnoreSearchScope.assertScope(shouldContain: List = emptyList(), shouldNotContain: List = emptyList()) { + for (path in shouldContain) { + assertFalse("'$path' should be included in the scope", isIgnored(repo.root.findFileByRelativePath(path)!!)) + } + for (path in shouldNotContain) { + assertTrue("'$path' should be excluded from the scope", isIgnored(repo.root.findFileByRelativePath(path)!!)) + } + } + + private fun gitIgnore(content: String) = gitIgnore(repo.root, content) + private fun gitIgnore(parentDit: VirtualFile, content: String) = parentDit.createFile(GITIGNORE, content) +} \ No newline at end of file diff --git a/plugins/git4idea/tests/git4idea/test/GitTestUtil.kt b/plugins/git4idea/tests/git4idea/test/GitTestUtil.kt index 3d4ca3a2c587..69cb142669a6 100644 --- a/plugins/git4idea/tests/git4idea/test/GitTestUtil.kt +++ b/plugins/git4idea/tests/git4idea/test/GitTestUtil.kt @@ -130,7 +130,7 @@ internal fun createRepository(project: Project, root: Path, makeInitialCommit: B internal fun GitRepository.createSubRepository(name: String): GitRepository { val childRoot = File(this.root.path, name) - HeavyPlatformTestCase.assertTrue(childRoot.mkdir()) + HeavyPlatformTestCase.assertTrue(childRoot.mkdirs()) val repo = createRepository(this.project, childRoot.path) this.tac(".gitignore", name) return repo