From f76c2ae3b78e6332e69cb1b5c183157dc0d629f0 Mon Sep 17 00:00:00 2001 From: Himanshu Garg Date: Tue, 9 Dec 2025 15:52:36 +0530 Subject: [PATCH] Add support for detecting affected projects in git submodules --- README.md | 31 +++++++++++++ .../AffectedModuleDetector.kt | 39 ++++++++++++++++ .../affectedmoduledetector/ProjectGraph.kt | 18 ++++++++ .../AffectedModuleDetectorImplTest.kt | 44 ++++++++++++++++++ .../ProjectGraphTest.kt | 46 +++++++++++++++++++ 5 files changed, 178 insertions(+) diff --git a/README.md b/README.md index 5d245fd..6b1eec0 100644 --- a/README.md +++ b/README.md @@ -129,6 +129,37 @@ affectedModuleDetector { - `top`: The top of the git log to use. Must be used in combination with configuration `includeUncommitted = false` - `customTasks`: set of [CustomTask](https://github.com/dropbox/AffectedModuleDetector/blob/main/affectedmoduledetector/src/main/kotlin/com/dropbox/affectedmoduledetector/AffectedModuleConfiguration.kt) +## Git Submodule Support + +The plugin automatically detects git submodules by parsing the `.gitmodules` file in the project root. When a submodule pointer changes (i.e., the submodule is updated to a different commit), all Gradle projects located under that submodule path are marked as affected. + +### How It Works + +1. **Auto-Detection**: The plugin reads `.gitmodules` to discover all submodule paths +2. **Change Detection**: When git reports a submodule path as changed, the plugin finds all Gradle projects under that path +3. **Dependency Propagation**: Affected submodule projects and their dependents are included in the affected set + +### Example + +Given this project structure: +``` +my-project/ +├── .gitmodules +├── libs/ +│ └── my-submodule/ # git submodule +│ ├── core/ # Gradle project :core +│ └── utils/ # Gradle project :utils +└── app/ # Gradle project :app (depends on :core) +``` + +When `libs/my-submodule` is updated to a new commit: +- `:core` and `:utils` are marked as **changed projects** +- `:app` is marked as a **dependent project** (because it depends on `:core`) + +### Nested Submodules + +The plugin handles nested submodules correctly. When a nested submodule changes, git reports the immediate parent submodule as changed, and the plugin finds all projects under that parent path. + By default, the Detector will look for `assembleAndroidDebugTest`, `connectedAndroidDebugTest`, and `testDebug`. Modules can specify a configuration block to specify which variant tests to run: ```groovy affectedTestConfiguration { diff --git a/affectedmoduledetector/src/main/kotlin/com/dropbox/affectedmoduledetector/AffectedModuleDetector.kt b/affectedmoduledetector/src/main/kotlin/com/dropbox/affectedmoduledetector/AffectedModuleDetector.kt index aac4a48..ca43706 100644 --- a/affectedmoduledetector/src/main/kotlin/com/dropbox/affectedmoduledetector/AffectedModuleDetector.kt +++ b/affectedmoduledetector/src/main/kotlin/com/dropbox/affectedmoduledetector/AffectedModuleDetector.kt @@ -507,6 +507,11 @@ class AffectedModuleDetectorImpl( private var unknownFiles: MutableSet = mutableSetOf() + /** Lazily loaded set of submodule paths from .gitmodules file */ + private val submodulePaths: Set by lazy { + parseSubmodulePaths() + } + override fun shouldInclude(project: ProjectPath): Boolean { val isProjectAffected = affectedProjects.contains(project) val isProjectProvided = isProjectProvided2(project) @@ -566,6 +571,15 @@ class AffectedModuleDetectorImpl( val changedProjects = mutableSetOf() for (filePath in changedFiles) { + if (submodulePaths.contains(filePath)) { + val submoduleProjects = projectGraph.findAllProjectsUnderPath(filePath, logger) + if (submoduleProjects.isNotEmpty()) { + changedProjects.addAll(submoduleProjects) + logger?.info("Submodule $filePath changed. Added ${submoduleProjects.size} projects: $submoduleProjects") + continue + } + } + val containingProject = findContainingProject(filePath) if (containingProject == null) { unknownFiles.add(filePath) @@ -662,6 +676,31 @@ class AffectedModuleDetectorImpl( logger?.info("search result for $filePath resulted in ${it?.path}") } } + + /** + * Parses .gitmodules file to extract submodule paths. + * Returns empty set if no .gitmodules file exists. + */ + private fun parseSubmodulePaths(): Set { + val gitmodulesFile = File(gitRoot, ".gitmodules") + if (!gitmodulesFile.exists()) { + logger?.info("No .gitmodules file found at ${gitmodulesFile.absolutePath}") + return emptySet() + } + + val pathRegex = Regex("""^\s*path\s*=\s*(.+)\s*$""") + val paths = mutableSetOf() + + gitmodulesFile.readLines().forEach { line -> + pathRegex.find(line)?.let { match -> + val path = match.groupValues[1].trim().replace("/", File.separator) + paths.add(path) + } + } + + logger?.info("Found ${paths.size} submodules: $paths") + return paths + } } val Project.isRoot get() = this == rootProject diff --git a/affectedmoduledetector/src/main/kotlin/com/dropbox/affectedmoduledetector/ProjectGraph.kt b/affectedmoduledetector/src/main/kotlin/com/dropbox/affectedmoduledetector/ProjectGraph.kt index ea3d4f5..d5793a2 100644 --- a/affectedmoduledetector/src/main/kotlin/com/dropbox/affectedmoduledetector/ProjectGraph.kt +++ b/affectedmoduledetector/src/main/kotlin/com/dropbox/affectedmoduledetector/ProjectGraph.kt @@ -70,6 +70,19 @@ class ProjectGraph(project: Project, logger: Logger? = null) : Serializable { return rootNode.find(sections, 0, logger) } + /** + * Finds all projects whose directory is under the given path prefix. + * Used for submodule support - when a submodule path changes, all projects under it are affected. + */ + fun findAllProjectsUnderPath(pathPrefix: String, logger: Logger? = null): Set { + val sections = pathPrefix.split(File.separatorChar) + val node = rootNode.findNode(sections, 0) ?: return emptySet() + val result = mutableSetOf() + node.addAllProjectPaths(result) + logger?.info("Found ${result.size} projects under $pathPrefix: $result") + return result + } + val allProjects by lazy { val result = mutableSetOf() rootNode.addAllProjectPaths(result) @@ -98,6 +111,11 @@ class ProjectGraph(project: Project, logger: Logger? = null) : Serializable { } } + fun findNode(sections: List, index: Int): Node? { + if (sections.size <= index) return this + return children[sections[index]]?.findNode(sections, index + 1) + } + fun addAllProjectPaths(collection: MutableSet) { projectPath?.let { path -> collection.add(path) } for (child in children.values) { diff --git a/affectedmoduledetector/src/test/kotlin/com/dropbox/affectedmoduledetector/AffectedModuleDetectorImplTest.kt b/affectedmoduledetector/src/test/kotlin/com/dropbox/affectedmoduledetector/AffectedModuleDetectorImplTest.kt index 2c8a4be..c2d3836 100644 --- a/affectedmoduledetector/src/test/kotlin/com/dropbox/affectedmoduledetector/AffectedModuleDetectorImplTest.kt +++ b/affectedmoduledetector/src/test/kotlin/com/dropbox/affectedmoduledetector/AffectedModuleDetectorImplTest.kt @@ -1773,6 +1773,50 @@ class AffectedModuleDetectorImplTest { ) } + @Test + fun `GIVEN submodule with projects WHEN submodule changes THEN all projects under submodule are affected`() { + // Create submodule directory with a project inside + val submodulePath = convertToFilePath("libs", "my-submodule") + val submoduleDir = File(tmpFolder.root, submodulePath) + submoduleDir.mkdirs() + + // Create .gitmodules file + File(tmpFolder.root, ".gitmodules").writeText(""" + [submodule "libs/my-submodule"] + path = libs/my-submodule + url = https://github.com/example/repo.git + """.trimIndent()) + + // Create a project inside the submodule + val submoduleProject = ProjectBuilder.builder() + .withProjectDir(submoduleDir.resolve("module-a")) + .withName("module-a") + .withParent(root) + .build() + + val submoduleProjectGraph = ProjectGraph(root, null) + + val detector = AffectedModuleDetectorImpl( + projectGraph = submoduleProjectGraph, + dependencyTracker = DependencyTracker(root, null), + logger = logger.toLogger(), + ignoreUnknownProjects = false, + projectSubset = ProjectSubset.CHANGED_PROJECTS, + modules = null, + changedFilesProvider = MockGitClient( + changedFiles = listOf(submodulePath), + tmpFolder = tmpFolder.root + ).findChangedFiles(root), + gitRoot = tmpFolder.root, + config = affectedModuleConfiguration + ) + + MatcherAssert.assertThat( + detector.affectedProjects, + CoreMatchers.hasItem(submoduleProject.projectPath) + ) + } + // For both Linux/Windows fun convertToFilePath(vararg list: String): String { return list.toList().joinToString(File.separator) diff --git a/affectedmoduledetector/src/test/kotlin/com/dropbox/affectedmoduledetector/ProjectGraphTest.kt b/affectedmoduledetector/src/test/kotlin/com/dropbox/affectedmoduledetector/ProjectGraphTest.kt index f88c21b..2c15918 100644 --- a/affectedmoduledetector/src/test/kotlin/com/dropbox/affectedmoduledetector/ProjectGraphTest.kt +++ b/affectedmoduledetector/src/test/kotlin/com/dropbox/affectedmoduledetector/ProjectGraphTest.kt @@ -62,5 +62,51 @@ class ProjectGraphTest { graph.findContainingProject("p2/a/b/c/d/e/f/a.java".toLocalPath()) ) } + + @Test + fun testFindAllProjectsUnderPath() { + val tmpDir = tmpFolder.root + val root = ProjectBuilder.builder() + .withProjectDir(tmpDir) + .withName("root") + .build() + (root.properties.get("ext") as ExtraPropertiesExtension).set("supportRootFolder", tmpDir) + + // Create submodule directory with multiple projects + val submoduleDir = tmpDir.resolve("submodule") + submoduleDir.mkdirs() + + val p1 = ProjectBuilder.builder() + .withProjectDir(submoduleDir.resolve("module-a")) + .withName("module-a") + .withParent(root) + .build() + val p2 = ProjectBuilder.builder() + .withProjectDir(submoduleDir.resolve("module-b")) + .withName("module-b") + .withParent(root) + .build() + + val graph = ProjectGraph(root, null) + val result = graph.findAllProjectsUnderPath("submodule") + + assertEquals(setOf(p1.projectPath, p2.projectPath), result) + } + + @Test + fun testFindAllProjectsUnderPath_returnsEmptyForNonexistent() { + val tmpDir = tmpFolder.root + val root = ProjectBuilder.builder() + .withProjectDir(tmpDir) + .withName("root") + .build() + (root.properties.get("ext") as ExtraPropertiesExtension).set("supportRootFolder", tmpDir) + + val graph = ProjectGraph(root, null) + val result = graph.findAllProjectsUnderPath("nonexistent") + + assertEquals(emptySet(), result) + } + private fun String.toLocalPath() = this.split("/").joinToString(File.separator) }