Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -507,6 +507,11 @@ class AffectedModuleDetectorImpl(

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

/** Lazily loaded set of submodule paths from .gitmodules file */
private val submodulePaths: Set<String> by lazy {
parseSubmodulePaths()
}

override fun shouldInclude(project: ProjectPath): Boolean {
val isProjectAffected = affectedProjects.contains(project)
val isProjectProvided = isProjectProvided2(project)
Expand Down Expand Up @@ -566,6 +571,15 @@ class AffectedModuleDetectorImpl(
val changedProjects = mutableSetOf<ProjectPath>()

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)
Expand Down Expand Up @@ -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<String> {
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<String>()

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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<ProjectPath> {
val sections = pathPrefix.split(File.separatorChar)
val node = rootNode.findNode(sections, 0) ?: return emptySet()
val result = mutableSetOf<ProjectPath>()
node.addAllProjectPaths(result)
logger?.info("Found ${result.size} projects under $pathPrefix: $result")
return result
}

val allProjects by lazy {
val result = mutableSetOf<ProjectPath>()
rootNode.addAllProjectPaths(result)
Expand Down Expand Up @@ -98,6 +111,11 @@ class ProjectGraph(project: Project, logger: Logger? = null) : Serializable {
}
}

fun findNode(sections: List<String>, index: Int): Node? {
if (sections.size <= index) return this
return children[sections[index]]?.findNode(sections, index + 1)
}

fun addAllProjectPaths(collection: MutableSet<ProjectPath>) {
projectPath?.let { path -> collection.add(path) }
for (child in children.values) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<ProjectPath>(), result)
}

private fun String.toLocalPath() = this.split("/").joinToString(File.separator)
}