Skip to content

Commit 98a3222

Browse files
Add support for mapping git submodules to Gradle projects
1 parent 67d2bfd commit 98a3222

File tree

4 files changed

+175
-2
lines changed

4 files changed

+175
-2
lines changed

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

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,33 @@ class AffectedModuleConfiguration : Serializable {
119119
*/
120120
var ignoredFiles = emptySet<String>()
121121

122+
/**
123+
* Maps submodule paths to their owning Gradle project paths.
124+
*
125+
* When a git submodule pointer changes, `git diff --name-only` returns just the
126+
* submodule directory path (e.g., "libs/my-submodule"), not the files inside.
127+
* Since submodules are not Gradle projects, they would normally become "unknown files"
128+
* and trigger a full build.
129+
*
130+
* Use this mapping to associate submodule paths with specific Gradle projects.
131+
* When a submodule changes, the mapped project (and its dependents) will be affected.
132+
*
133+
* Example:
134+
* ```
135+
* submoduleToProjectMapping = [
136+
* "libs/my-submodule": ":core",
137+
* "external/shared-lib": ":app"
138+
* ]
139+
* ```
140+
*
141+
* Keys are relative paths from the git root (use forward slashes).
142+
* Values are Gradle project paths (e.g., ":app", ":libs:core").
143+
*/
144+
var submoduleToProjectMapping = mapOf<String, String>()
145+
set(value) {
146+
field = value.mapKeys { it.key.toOsSpecificPath() }
147+
}
148+
122149
/**
123150
* If uncommitted files should be considered affected
124151
*/

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

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -658,8 +658,14 @@ class AffectedModuleDetectorImpl(
658658
}
659659

660660
private fun findContainingProject(filePath: String): ProjectPath? {
661-
return projectGraph.findContainingProject(filePath, logger).also {
662-
logger?.info("search result for $filePath resulted in ${it?.path}")
661+
return projectGraph.findContainingProject(filePath, logger)?.also {
662+
logger?.info("search result for $filePath resulted in ${it.path}")
663+
} ?: config.submoduleToProjectMapping[filePath]?.let {
664+
logger?.info("File $filePath mapped to project $it via submoduleToProjectMapping")
665+
ProjectPath(it)
666+
} ?: run {
667+
logger?.info("search result for $filePath resulted in null")
668+
null
663669
}
664670
}
665671
}

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

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -376,4 +376,35 @@ class AffectedModuleConfigurationTest {
376376
// THEN
377377
assertFalse(actual)
378378
}
379+
380+
@Test
381+
fun `GIVEN AffectedModuleConfiguration WHEN submoduleToProjectMapping THEN default value is empty map`() {
382+
// WHEN
383+
val mapping = config.submoduleToProjectMapping
384+
385+
// THEN
386+
assertThat(mapping).isEmpty()
387+
}
388+
389+
@Test
390+
fun `GIVEN AffectedModuleConfiguration WHEN submoduleToProjectMapping is set THEN value is returned`() {
391+
// GIVEN
392+
val expectedMapping = mapOf(
393+
"libs/my-submodule" to ":core",
394+
"external/another-submodule" to ":feature",
395+
)
396+
397+
// WHEN
398+
config.submoduleToProjectMapping = expectedMapping
399+
400+
// THEN
401+
assertThat(config.submoduleToProjectMapping).containsEntry(
402+
"libs${File.separator}my-submodule".replace("/", File.separator),
403+
":core",
404+
)
405+
assertThat(config.submoduleToProjectMapping).containsEntry(
406+
"external${File.separator}another-submodule".replace("/", File.separator),
407+
":feature",
408+
)
409+
}
379410
}

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

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

1776+
@Test
1777+
fun `GIVEN submodule path in submoduleToProjectMapping WHEN submodule changes THEN mapped project is affected`() {
1778+
// Create a submodule directory (not a Gradle project)
1779+
val submodulePath = convertToFilePath("libs", "my-submodule")
1780+
File(tmpFolder.root, submodulePath).mkdirs()
1781+
1782+
val config = AffectedModuleConfiguration().also {
1783+
it.baseDir = tmpFolder.root.absolutePath
1784+
it.pathsAffectingAllModules = pathsAffectingAllModules
1785+
// Map the submodule to project p1
1786+
it.submoduleToProjectMapping = mapOf(submodulePath to ":p1")
1787+
}
1788+
1789+
val detector = AffectedModuleDetectorImpl(
1790+
projectGraph = rootProjectGraph,
1791+
dependencyTracker = rootDependencyTracker,
1792+
logger = logger.toLogger(),
1793+
ignoreUnknownProjects = false,
1794+
projectSubset = ProjectSubset.CHANGED_PROJECTS,
1795+
modules = null,
1796+
changedFilesProvider = MockGitClient(
1797+
changedFiles = listOf(submodulePath),
1798+
tmpFolder = tmpFolder.root
1799+
).findChangedFiles(root),
1800+
gitRoot = tmpFolder.root,
1801+
config = config
1802+
)
1803+
1804+
// The submodule change should be mapped to :p1
1805+
MatcherAssert.assertThat(
1806+
detector.affectedProjects,
1807+
CoreMatchers.`is`(
1808+
setOf(p1.projectPath)
1809+
)
1810+
)
1811+
}
1812+
1813+
@Test
1814+
fun `GIVEN submodule in submoduleToProjectMapping WHEN submodule changes THEN dependents are also affected`() {
1815+
// Create a submodule directory
1816+
val submodulePath = convertToFilePath("libs", "my-submodule")
1817+
File(tmpFolder.root, submodulePath).mkdirs()
1818+
1819+
val config = AffectedModuleConfiguration().also {
1820+
it.baseDir = tmpFolder.root.absolutePath
1821+
it.pathsAffectingAllModules = pathsAffectingAllModules
1822+
// Map the submodule to project p1 (which has dependents p3)
1823+
it.submoduleToProjectMapping = mapOf(submodulePath to ":p1")
1824+
}
1825+
1826+
val detector = AffectedModuleDetectorImpl(
1827+
projectGraph = rootProjectGraph,
1828+
dependencyTracker = rootDependencyTracker,
1829+
logger = logger.toLogger(),
1830+
ignoreUnknownProjects = false,
1831+
projectSubset = ProjectSubset.ALL_AFFECTED_PROJECTS,
1832+
modules = null,
1833+
changedFilesProvider = MockGitClient(
1834+
changedFiles = listOf(submodulePath),
1835+
tmpFolder = tmpFolder.root
1836+
).findChangedFiles(root),
1837+
gitRoot = tmpFolder.root,
1838+
config = config
1839+
)
1840+
1841+
// The submodule change mapped to :p1 should also affect :p1:p3 (dependent)
1842+
MatcherAssert.assertThat(
1843+
detector.affectedProjects,
1844+
CoreMatchers.hasItems(p1.projectPath, p3.projectPath)
1845+
)
1846+
}
1847+
1848+
@Test
1849+
fun `GIVEN unknown path not in submoduleToProjectMapping WHEN path changes THEN added to unknownFiles and no project affected`() {
1850+
// Create a directory that's not a Gradle project and not mapped
1851+
val unknownPath = convertToFilePath("libs", "unknown-lib")
1852+
File(tmpFolder.root, unknownPath).mkdirs()
1853+
1854+
val config = AffectedModuleConfiguration().also {
1855+
it.baseDir = tmpFolder.root.absolutePath
1856+
it.pathsAffectingAllModules = pathsAffectingAllModules
1857+
// No mapping for this path
1858+
}
1859+
1860+
val detector = AffectedModuleDetectorImpl(
1861+
projectGraph = rootProjectGraph,
1862+
dependencyTracker = rootDependencyTracker,
1863+
logger = logger.toLogger(),
1864+
ignoreUnknownProjects = false,
1865+
projectSubset = ProjectSubset.ALL_AFFECTED_PROJECTS,
1866+
modules = null,
1867+
changedFilesProvider = MockGitClient(
1868+
changedFiles = listOf(unknownPath),
1869+
tmpFolder = tmpFolder.root
1870+
).findChangedFiles(root),
1871+
gitRoot = tmpFolder.root,
1872+
config = config
1873+
)
1874+
1875+
// Unknown path without mapping goes to unknownFiles,
1876+
// since there's no containing project, affectedProjects is empty
1877+
MatcherAssert.assertThat(
1878+
detector.affectedProjects,
1879+
CoreMatchers.`is`(
1880+
setOf()
1881+
)
1882+
)
1883+
}
1884+
17761885
// For both Linux/Windows
17771886
fun convertToFilePath(vararg list: String): String {
17781887
return list.toList().joinToString(File.separator)

0 commit comments

Comments
 (0)