diff --git a/platform/vcs-impl/src/com/intellij/openapi/vcs/roots/VcsRootDetectorImpl.java b/platform/vcs-impl/src/com/intellij/openapi/vcs/roots/VcsRootDetectorImpl.java index 9980b3bc7fe1..b38065c0ae73 100644 --- a/platform/vcs-impl/src/com/intellij/openapi/vcs/roots/VcsRootDetectorImpl.java +++ b/platform/vcs-impl/src/com/intellij/openapi/vcs/roots/VcsRootDetectorImpl.java @@ -77,7 +77,7 @@ final class VcsRootDetectorImpl implements VcsRootDetector { detectedRoots.addAll(scanForRootsInsideDir(myProject, dirToScan, null, scannedDirs)); detectedRoots.addAll(scanForRootsAboveDirs(Collections.singletonList(dirToScan), scannedDirs, detectedRoots)); - return detectedRoots; + return deduplicate(detectedRoots); } private @NotNull Collection scanForRootsInContentRoots() { @@ -106,7 +106,43 @@ final class VcsRootDetectorImpl implements VcsRootDetector { detectedAndKnownRoots.addAll(Arrays.asList(ProjectLevelVcsManager.getInstance(myProject).getAllVcsRoots())); detectedRoots.addAll(scanDependentRoots(scannedDirs, detectedAndKnownRoots)); - return detectedRoots; + return deduplicate(detectedRoots); + } + + /** + * @return a deduplicated set of {@code detectedRoots} with removed links pointing to the same canonical file. + */ + private static @NotNull Set deduplicate(@NotNull Set detectedRoots) { + if (detectedRoots.size() <= 1) return detectedRoots; + + Set result = new HashSet<>(); + + Set processedCanonicalRoots = new HashSet<>(); + Set rootsUnderSymlink = new HashSet<>(); + for (VcsRoot root : detectedRoots) { + VirtualFile path = root.getPath(); + VirtualFile canonicalPath = path.getCanonicalFile(); + if (canonicalPath != null && !path.equals(canonicalPath)) { + rootsUnderSymlink.add(root); + } else { + processedCanonicalRoots.add(path); + result.add(root); + } + } + + if (rootsUnderSymlink.isEmpty()) return detectedRoots; + + for (VcsRoot root : ContainerUtil.sorted(rootsUnderSymlink, Comparator.comparing(root -> root.getPath().toNioPath()))) { + VirtualFile canonicalFile = root.getPath().getCanonicalFile(); + if (processedCanonicalRoots.contains(canonicalFile)) { + LOG.debug("Skipping duplicate VCS root %s: root for canonical file '%s' is already detected".formatted(root, canonicalFile)); + } else { + processedCanonicalRoots.add(canonicalFile); + result.add(root); + } + } + + return result; } private @NotNull Set scanForRootsInsideDir(@NotNull Project project, diff --git a/platform/vcs-tests/testSrc/com/intellij/openapi/vcs/roots/VcsRootDetectorTest.kt b/platform/vcs-tests/testSrc/com/intellij/openapi/vcs/roots/VcsRootDetectorTest.kt index b93158d0502b..80fa91494cab 100644 --- a/platform/vcs-tests/testSrc/com/intellij/openapi/vcs/roots/VcsRootDetectorTest.kt +++ b/platform/vcs-tests/testSrc/com/intellij/openapi/vcs/roots/VcsRootDetectorTest.kt @@ -1,4 +1,4 @@ -// Copyright 2000-2020 JetBrains s.r.o. Use of this source code is governed by the Apache 2.0 license that can be found in the LICENSE file. +// Copyright 2000-2025 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. package com.intellij.openapi.vcs.roots import com.intellij.openapi.components.service @@ -8,8 +8,11 @@ import com.intellij.openapi.vcs.VcsTestUtil.assertEqualCollections import com.intellij.openapi.vfs.VirtualFile import com.intellij.testFramework.PsiTestUtil import com.intellij.testFramework.VfsTestUtil +import com.intellij.testFramework.utils.io.createDirectory +import com.intellij.testFramework.utils.io.createFile import org.jetbrains.jps.model.serialization.PathMacroUtil import java.util.Collections.emptyList +import kotlin.io.path.createSymbolicLinkPointingTo class VcsRootDetectorTest : VcsRootBaseTest() { fun `test no roots`() { @@ -164,6 +167,95 @@ class VcsRootDetectorTest : VcsRootBaseTest() { expect(projectRoot) } + // IJPL-172487 + fun `test root referenced via child symlink should not be duplicated`() { + initRepository(projectRoot) + + // Created layout: + // project/ + // -child/ + // --test + // --root-symlink -> project + val targetDir = projectNioRoot.createDirectory("child") + targetDir.createFile("test") + targetDir.resolve("root-symlink").createSymbolicLinkPointingTo(projectNioRoot) + + VfsTestUtil.syncRefresh() + + PsiTestUtil.addContentRoot(rootModule, projectRoot) + + expect(projectRoot) + } + + // IJPL-181017 + fun `test root has symlink parent`() { + val symlink = projectNioRoot.resolve("symlink") + val projectSibling = testNioRoot.createDirectory("project-sibling") + symlink.createSymbolicLinkPointingTo(projectSibling) + VfsTestUtil.syncRefresh() + + val vcsRoot = createVcsRoots("symlink/repo-root") + // Expected `project/symlink/repo-root` which is not a canonical path `project-sibling/repo-root` + expect(vcsRoot) + } + + + fun `test multiple roots under symlink`() { + val symlink = projectNioRoot.resolve("symlink") + val projectSibling = testNioRoot.createDirectory("project-sibling") + symlink.createSymbolicLinkPointingTo(projectSibling) + VfsTestUtil.syncRefresh() + + // Created layout: + // project/ + // -symlink -> project-sibling + // project-sibling/ + // -root1 + // -root2 + // -root3 + val roots = createVcsRoots("symlink/root1", "symlink/root2", "symlink/root3") + expect(roots) + } + + fun `test multiple roots with symlink target outside project`() { + initRepository(projectRoot) + + val symlink = projectNioRoot.resolve("symlink") + val projectSibling = testNioRoot.createDirectory("project-sibling") + symlink.createSymbolicLinkPointingTo(projectSibling) + VfsTestUtil.syncRefresh() + + val vcsRoot = createVcsRoots("symlink/repo-root") + expect(vcsRoot + projectRoot) + } + + // Combined scenario for IJPL-172487 and IJPL-181017 + fun `test symlinks under symlinks`() { + val symlink = projectNioRoot.resolve("symlink") + val projectSibling = testNioRoot.createDirectory("project-sibling") + symlink.createSymbolicLinkPointingTo(projectSibling) + VfsTestUtil.syncRefresh() + + val vcsRoot = createVcsRoots("symlink/repo-root") + + // Created layout: + // project/ + // -symlink -> project-sibling/ + // project-sibling/ + // -repo-root + // --test + // --sublink -> repo-root + val repoRoot = symlink.resolve("repo-root") + repoRoot.createFile("test") + val linkToRepo = repoRoot.resolve("sublink") + linkToRepo.createSymbolicLinkPointingTo(repoRoot) + + VfsTestUtil.syncRefresh() + + // Expected only `project/symlink/repo-root` + expect(vcsRoot) + } + private fun createVcsRoots(vararg relativePaths: String, registerContentRoot: Boolean = true): List { return createVcsRoots(listOf(*relativePaths), registerContentRoot) }