Skip to content

Commit cdb0a7c

Browse files
feat: Auto-detect git submodules and mark all projects under them as affected
1 parent b9a2be6 commit cdb0a7c

File tree

4 files changed

+147
-0
lines changed

4 files changed

+147
-0
lines changed

affectedmoduledetector/src/main/kotlin/com/dropbox/affectedmoduledetector/AffectedModuleDetector.kt

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -507,6 +507,11 @@ class AffectedModuleDetectorImpl(
507507

508508
private var unknownFiles: MutableSet<String> = mutableSetOf()
509509

510+
/** Lazily loaded set of submodule paths from .gitmodules file */
511+
private val submodulePaths: Set<String> by lazy {
512+
parseSubmodulePaths()
513+
}
514+
510515
override fun shouldInclude(project: ProjectPath): Boolean {
511516
val isProjectAffected = affectedProjects.contains(project)
512517
val isProjectProvided = isProjectProvided2(project)
@@ -566,6 +571,15 @@ class AffectedModuleDetectorImpl(
566571
val changedProjects = mutableSetOf<ProjectPath>()
567572

568573
for (filePath in changedFiles) {
574+
if (submodulePaths.contains(filePath)) {
575+
val submoduleProjects = projectGraph.findAllProjectsUnderPath(filePath, logger)
576+
if (submoduleProjects.isNotEmpty()) {
577+
changedProjects.addAll(submoduleProjects)
578+
logger?.info("Submodule $filePath changed. Added ${submoduleProjects.size} projects: $submoduleProjects")
579+
continue
580+
}
581+
}
582+
569583
val containingProject = findContainingProject(filePath)
570584
if (containingProject == null) {
571585
unknownFiles.add(filePath)
@@ -662,6 +676,31 @@ class AffectedModuleDetectorImpl(
662676
logger?.info("search result for $filePath resulted in ${it?.path}")
663677
}
664678
}
679+
680+
/**
681+
* Parses .gitmodules file to extract submodule paths.
682+
* Returns empty set if no .gitmodules file exists.
683+
*/
684+
private fun parseSubmodulePaths(): Set<String> {
685+
val gitmodulesFile = File(gitRoot, ".gitmodules")
686+
if (!gitmodulesFile.exists()) {
687+
logger?.info("No .gitmodules file found at ${gitmodulesFile.absolutePath}")
688+
return emptySet()
689+
}
690+
691+
val pathRegex = Regex("""^\s*path\s*=\s*(.+)\s*$""")
692+
val paths = mutableSetOf<String>()
693+
694+
gitmodulesFile.readLines().forEach { line ->
695+
pathRegex.find(line)?.let { match ->
696+
val path = match.groupValues[1].trim().replace("/", File.separator)
697+
paths.add(path)
698+
}
699+
}
700+
701+
logger?.info("Found ${paths.size} submodules: $paths")
702+
return paths
703+
}
665704
}
666705

667706
val Project.isRoot get() = this == rootProject

affectedmoduledetector/src/main/kotlin/com/dropbox/affectedmoduledetector/ProjectGraph.kt

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,19 @@ class ProjectGraph(project: Project, logger: Logger? = null) : Serializable {
7070
return rootNode.find(sections, 0, logger)
7171
}
7272

73+
/**
74+
* Finds all projects whose directory is under the given path prefix.
75+
* Used for submodule support - when a submodule path changes, all projects under it are affected.
76+
*/
77+
fun findAllProjectsUnderPath(pathPrefix: String, logger: Logger? = null): Set<ProjectPath> {
78+
val sections = pathPrefix.split(File.separatorChar)
79+
val node = rootNode.findNode(sections, 0) ?: return emptySet()
80+
val result = mutableSetOf<ProjectPath>()
81+
node.addAllProjectPaths(result)
82+
logger?.info("Found ${result.size} projects under $pathPrefix: $result")
83+
return result
84+
}
85+
7386
val allProjects by lazy {
7487
val result = mutableSetOf<ProjectPath>()
7588
rootNode.addAllProjectPaths(result)
@@ -98,6 +111,11 @@ class ProjectGraph(project: Project, logger: Logger? = null) : Serializable {
98111
}
99112
}
100113

114+
fun findNode(sections: List<String>, index: Int): Node? {
115+
if (sections.size <= index) return this
116+
return children[sections[index]]?.findNode(sections, index + 1)
117+
}
118+
101119
fun addAllProjectPaths(collection: MutableSet<ProjectPath>) {
102120
projectPath?.let { path -> collection.add(path) }
103121
for (child in children.values) {

affectedmoduledetector/src/test/kotlin/com/dropbox/affectedmoduledetector/AffectedModuleDetectorImplTest.kt

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1773,6 +1773,50 @@ class AffectedModuleDetectorImplTest {
17731773
)
17741774
}
17751775

1776+
@Test
1777+
fun `GIVEN submodule with projects WHEN submodule changes THEN all projects under submodule are affected`() {
1778+
// Create submodule directory with a project inside
1779+
val submodulePath = convertToFilePath("libs", "my-submodule")
1780+
val submoduleDir = File(tmpFolder.root, submodulePath)
1781+
submoduleDir.mkdirs()
1782+
1783+
// Create .gitmodules file
1784+
File(tmpFolder.root, ".gitmodules").writeText("""
1785+
[submodule "libs/my-submodule"]
1786+
path = libs/my-submodule
1787+
url = https://github.com/example/repo.git
1788+
""".trimIndent())
1789+
1790+
// Create a project inside the submodule
1791+
val submoduleProject = ProjectBuilder.builder()
1792+
.withProjectDir(submoduleDir.resolve("module-a"))
1793+
.withName("module-a")
1794+
.withParent(root)
1795+
.build()
1796+
1797+
val submoduleProjectGraph = ProjectGraph(root, null)
1798+
1799+
val detector = AffectedModuleDetectorImpl(
1800+
projectGraph = submoduleProjectGraph,
1801+
dependencyTracker = DependencyTracker(root, null),
1802+
logger = logger.toLogger(),
1803+
ignoreUnknownProjects = false,
1804+
projectSubset = ProjectSubset.CHANGED_PROJECTS,
1805+
modules = null,
1806+
changedFilesProvider = MockGitClient(
1807+
changedFiles = listOf(submodulePath),
1808+
tmpFolder = tmpFolder.root
1809+
).findChangedFiles(root),
1810+
gitRoot = tmpFolder.root,
1811+
config = affectedModuleConfiguration
1812+
)
1813+
1814+
MatcherAssert.assertThat(
1815+
detector.affectedProjects,
1816+
CoreMatchers.hasItem(submoduleProject.projectPath)
1817+
)
1818+
}
1819+
17761820
// For both Linux/Windows
17771821
fun convertToFilePath(vararg list: String): String {
17781822
return list.toList().joinToString(File.separator)

affectedmoduledetector/src/test/kotlin/com/dropbox/affectedmoduledetector/ProjectGraphTest.kt

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,5 +62,51 @@ class ProjectGraphTest {
6262
graph.findContainingProject("p2/a/b/c/d/e/f/a.java".toLocalPath())
6363
)
6464
}
65+
66+
@Test
67+
fun testFindAllProjectsUnderPath() {
68+
val tmpDir = tmpFolder.root
69+
val root = ProjectBuilder.builder()
70+
.withProjectDir(tmpDir)
71+
.withName("root")
72+
.build()
73+
(root.properties.get("ext") as ExtraPropertiesExtension).set("supportRootFolder", tmpDir)
74+
75+
// Create submodule directory with multiple projects
76+
val submoduleDir = tmpDir.resolve("submodule")
77+
submoduleDir.mkdirs()
78+
79+
val p1 = ProjectBuilder.builder()
80+
.withProjectDir(submoduleDir.resolve("module-a"))
81+
.withName("module-a")
82+
.withParent(root)
83+
.build()
84+
val p2 = ProjectBuilder.builder()
85+
.withProjectDir(submoduleDir.resolve("module-b"))
86+
.withName("module-b")
87+
.withParent(root)
88+
.build()
89+
90+
val graph = ProjectGraph(root, null)
91+
val result = graph.findAllProjectsUnderPath("submodule")
92+
93+
assertEquals(setOf(p1.projectPath, p2.projectPath), result)
94+
}
95+
96+
@Test
97+
fun testFindAllProjectsUnderPath_returnsEmptyForNonexistent() {
98+
val tmpDir = tmpFolder.root
99+
val root = ProjectBuilder.builder()
100+
.withProjectDir(tmpDir)
101+
.withName("root")
102+
.build()
103+
(root.properties.get("ext") as ExtraPropertiesExtension).set("supportRootFolder", tmpDir)
104+
105+
val graph = ProjectGraph(root, null)
106+
val result = graph.findAllProjectsUnderPath("nonexistent")
107+
108+
assertEquals(emptySet<ProjectPath>(), result)
109+
}
110+
65111
private fun String.toLocalPath() = this.split("/").joinToString(File.separator)
66112
}

0 commit comments

Comments
 (0)