1 /* <lambda>null2 * Copyright 2018 The Android Open Source Project 3 * 4 * Licensed under the Apache License, Version 2.0 (the "License"); 5 * you may not use this file except in compliance with the License. 6 * You may obtain a copy of the License at 7 * 8 * http://www.apache.org/licenses/LICENSE-2.0 9 * 10 * Unless required by applicable law or agreed to in writing, software 11 * distributed under the License is distributed on an "AS IS" BASIS, 12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 * See the License for the specific language governing permissions and 14 * limitations under the License. 15 */ 16 17 package androidx.build.dependencyTracker 18 19 import androidx.build.dependencyTracker.AffectedModuleDetector.Companion.ENABLE_ARG 20 import androidx.build.getCheckoutRoot 21 import androidx.build.getDistributionDirectory 22 import androidx.build.gitclient.getChangedFilesProvider 23 import androidx.build.gradle.isRoot 24 import java.io.File 25 import org.gradle.api.Action 26 import org.gradle.api.GradleException 27 import org.gradle.api.Project 28 import org.gradle.api.Task 29 import org.gradle.api.invocation.Gradle 30 import org.gradle.api.logging.Logger 31 import org.gradle.api.provider.Provider 32 import org.gradle.api.services.BuildService 33 import org.gradle.api.services.BuildServiceParameters 34 import org.gradle.api.services.BuildServiceSpec 35 36 /** 37 * The subsets we allow the projects to be partitioned into. This is to allow more granular testing. 38 * Specifically, to enable running large tests on CHANGED_PROJECTS, while still only running small 39 * and medium tests on DEPENDENT_PROJECTS. 40 * 41 * The ProjectSubset specifies which projects we are interested in testing. The 42 * AffectedModuleDetector determines the minimum set of projects that must be built in order to run 43 * all the tests along with their runtime dependencies. 44 * 45 * The subsets are: CHANGED_PROJECTS -- The containing projects for any files that were changed in 46 * this CL. 47 * 48 * DEPENDENT_PROJECTS -- Any projects that have a dependency on any of the projects in the 49 * CHANGED_PROJECTS set. 50 * 51 * NONE -- A status to return for a project when it is not supposed to be built. 52 */ 53 enum class ProjectSubset { 54 DEPENDENT_PROJECTS, 55 CHANGED_PROJECTS, 56 NONE 57 } 58 59 /** 60 * A utility class that can discover which files are changed based on git history. 61 * 62 * To enable this, you need to pass [ENABLE_ARG] into the build as a command line parameter 63 * (-P<name>) 64 * 65 * Currently, it checks git logs to find last merge CL to discover where the anchor CL is. 66 * 67 * Eventually, we'll move to the props passed down by the build system when it is available. 68 * 69 * Since this needs to check project dependency graph to work, it cannot be accessed before all 70 * projects are loaded. Doing so will throw an exception. 71 */ 72 abstract class AffectedModuleDetector(protected val logger: Logger?) { 73 /** Returns whether this project was affected by current changes. */ shouldIncludenull74 abstract fun shouldInclude(project: String): Boolean 75 76 /** Returns whether this task was affected by current changes. */ 77 open fun shouldInclude(task: Task): Boolean { 78 val projectPath = getProjectPathFromTaskPath(task.path) 79 val include = shouldInclude(projectPath) 80 val inclusionVerb = if (include) "Including" else "Excluding" 81 logger?.info("$inclusionVerb task ${task.path}") 82 return include 83 } 84 85 /** 86 * Returns the set that the project belongs to. The set is one of the ProjectSubset above. This 87 * is used by the test config generator. 88 */ getSubsetnull89 abstract fun getSubset(projectPath: String): ProjectSubset 90 91 fun getProjectPathFromTaskPath(taskPath: String): String { 92 val lastColonIndex = taskPath.lastIndexOf(":") 93 val projectPath = taskPath.substring(0, lastColonIndex) 94 return projectPath 95 } 96 97 companion object { 98 private const val ROOT_PROP_NAME = "affectedModuleDetector" 99 private const val SERVICE_NAME = ROOT_PROP_NAME + "BuildService" 100 private const val LOG_FILE_NAME = "affected_module_detector_log.txt" 101 const val ENABLE_ARG = "androidx.enableAffectedModuleDetection" 102 const val BASE_COMMIT_ARG = "androidx.affectedModuleDetector.baseCommit" 103 104 @JvmStatic configurenull105 fun configure(gradle: Gradle, rootProject: Project) { 106 // Make an AffectedModuleDetectorWrapper that callers can save before the real 107 // AffectedModuleDetector is ready. Callers won't be able to use it until the wrapped 108 // detector has been assigned, but configureTaskGuard can still reference it in 109 // closures that will execute during task execution. 110 val instance = AffectedModuleDetectorWrapper() 111 rootProject.extensions.add(ROOT_PROP_NAME, instance) 112 113 val enabledProvider = rootProject.providers.gradleProperty(ENABLE_ARG) 114 val enabled = enabledProvider.isPresent() && enabledProvider.get() != "false" 115 116 val distDir = rootProject.getDistributionDirectory() 117 val outputFile = distDir.resolve(LOG_FILE_NAME) 118 119 outputFile.writeText("") 120 val logger = FileLogger(outputFile) 121 logger.info("setup: enabled: $enabled") 122 if (!enabled) { 123 val provider = 124 setupWithParams( 125 rootProject, 126 { spec -> 127 val params = spec.parameters 128 params.acceptAll = true 129 params.log = logger 130 } 131 ) 132 logger.info("using AcceptAll") 133 instance.wrapped = provider 134 return 135 } 136 val baseCommitOverride: Provider<String> = 137 rootProject.providers.gradleProperty(BASE_COMMIT_ARG) 138 139 gradle.taskGraph.whenReady { 140 logger.lifecycle("projects evaluated") 141 val projectGraph = ProjectGraph(rootProject) 142 val dependencyTracker = DependencyTracker(rootProject, logger.toLogger()) 143 val provider = 144 setupWithParams(rootProject) { spec -> 145 val params = spec.parameters 146 params.rootDir = rootProject.projectDir 147 params.checkoutRoot = rootProject.getCheckoutRoot() 148 params.projectGraph = projectGraph 149 params.dependencyTracker = dependencyTracker 150 params.log = logger 151 params.baseCommitOverride = baseCommitOverride 152 params.gitChangedFilesProvider = 153 rootProject.getChangedFilesProvider(baseCommitOverride) 154 } 155 logger.info("using real detector") 156 instance.wrapped = provider 157 } 158 } 159 setupWithParamsnull160 private fun setupWithParams( 161 rootProject: Project, 162 configureAction: Action<BuildServiceSpec<AffectedModuleDetectorLoader.Parameters>> 163 ): Provider<AffectedModuleDetectorLoader> { 164 if (!rootProject.isRoot) { 165 throw IllegalArgumentException("this should've been the root project") 166 } 167 return rootProject.gradle.sharedServices.registerIfAbsent( 168 SERVICE_NAME, 169 AffectedModuleDetectorLoader::class.java, 170 configureAction 171 ) 172 } 173 getInstancenull174 fun getInstance(project: Project): AffectedModuleDetector { 175 val extensions = project.rootProject.extensions 176 @Suppress("UNCHECKED_CAST") 177 val detector = extensions.findByName(ROOT_PROP_NAME) as? AffectedModuleDetector 178 return detector!! 179 } 180 181 /** 182 * Call this method to configure the given task to execute only if the owner project is 183 * affected by current changes 184 */ 185 @Throws(GradleException::class) 186 @JvmStatic configureTaskGuardnull187 fun configureTaskGuard(task: Task) { 188 val detector = getInstance(task.project) 189 task.onlyIf { detector.shouldInclude(task) } 190 } 191 } 192 } 193 194 /** 195 * Wrapper for AffectedModuleDetector Callers can access this wrapper during project configuration 196 * and save it until task execution time when the wrapped detector is ready for use (after the 197 * project graph is ready) 198 */ 199 class AffectedModuleDetectorWrapper : AffectedModuleDetector(logger = null) { 200 // We save a provider to a build service that knows how to make an 201 // AffectedModuleDetectorImpl because: 202 // An AffectedModuleDetectorImpl saves the list of modified files and affected 203 // modules to avoid having to recompute it for each task. However, that list can 204 // change across builds and we want to recompute it in each build. This requires 205 // creating a new AffectedModuleDetectorImpl in each build. 206 // To get Gradle to create a new AffectedModuleDetectorImpl in each build, we need 207 // to pass around a provider to a build service and query it from each task. 208 // The build service gets recreated when absent and reused when present. Then the 209 // build service will return the same AffectedModuleDetectorImpl for each task in 210 // a build 211 var wrapped: Provider<AffectedModuleDetectorLoader>? = null 212 getOrThrownull213 fun getOrThrow(): AffectedModuleDetector { 214 return wrapped?.get()?.detector 215 ?: throw GradleException( 216 """ 217 Tried to get the affected module detector implementation too early. 218 You cannot access it until all projects are evaluated. 219 """ 220 .trimIndent() 221 ) 222 } 223 getSubsetnull224 override fun getSubset(projectPath: String): ProjectSubset { 225 return getOrThrow().getSubset(projectPath) 226 } 227 shouldIncludenull228 override fun shouldInclude(project: String): Boolean { 229 return getOrThrow().shouldInclude(project) 230 } 231 shouldIncludenull232 override fun shouldInclude(task: Task): Boolean { 233 return getOrThrow().shouldInclude(task) 234 } 235 } 236 237 /** 238 * Stores the parameters of an AffectedModuleDetector and creates one when needed. The parameters 239 * here may be deserialized and loaded from Gradle's configuration cache when the configuration 240 * cache is enabled. 241 */ 242 abstract class AffectedModuleDetectorLoader : 243 BuildService<AffectedModuleDetectorLoader.Parameters> { 244 interface Parameters : BuildServiceParameters { 245 var acceptAll: Boolean 246 247 var rootDir: File 248 var checkoutRoot: File 249 var projectGraph: ProjectGraph 250 var dependencyTracker: DependencyTracker 251 var log: FileLogger? 252 var cobuiltTestPaths: Set<Set<String>>? 253 var alwaysBuildIfExists: Set<String>? 254 var ignoredPaths: Set<String>? 255 var baseCommitOverride: Provider<String>? 256 var gitChangedFilesProvider: Provider<List<String>> 257 } 258 <lambda>null259 val detector: AffectedModuleDetector by lazy { 260 val logger = parameters.log!! 261 if (parameters.acceptAll) { 262 AcceptAll(null) 263 } else { 264 AffectedModuleDetectorImpl( 265 projectGraph = parameters.projectGraph, 266 dependencyTracker = parameters.dependencyTracker, 267 logger = logger.toLogger(), 268 cobuiltTestPaths = 269 parameters.cobuiltTestPaths ?: AffectedModuleDetectorImpl.COBUILT_TEST_PATHS, 270 alwaysBuildIfExists = 271 parameters.alwaysBuildIfExists 272 ?: AffectedModuleDetectorImpl.ALWAYS_BUILD_IF_EXISTS, 273 ignoredPaths = parameters.ignoredPaths ?: AffectedModuleDetectorImpl.IGNORED_PATHS, 274 changedFilesProvider = parameters.gitChangedFilesProvider 275 ) 276 } 277 } 278 } 279 280 /** Implementation that accepts everything without checking. */ 281 private class AcceptAll(logger: Logger? = null) : AffectedModuleDetector(logger) { shouldIncludenull282 override fun shouldInclude(project: String): Boolean { 283 logger?.info("[AcceptAll] acceptAll.shouldInclude returning true") 284 return true 285 } 286 getSubsetnull287 override fun getSubset(projectPath: String): ProjectSubset { 288 logger?.info("[AcceptAll] AcceptAll.getSubset returning CHANGED_PROJECTS") 289 return ProjectSubset.CHANGED_PROJECTS 290 } 291 } 292 293 /** 294 * Real implementation that checks git logs to decide what is affected. 295 * 296 * If any file outside a module is changed, we assume everything has changed. 297 * 298 * When a file in a module is changed, all modules that depend on it are considered as changed. 299 */ 300 class AffectedModuleDetectorImpl( 301 private val projectGraph: ProjectGraph, 302 private val dependencyTracker: DependencyTracker, 303 logger: Logger?, 304 // used for debugging purposes when we want to ignore non module files 305 @Suppress("unused") private val ignoreUnknownProjects: Boolean = false, 306 private val cobuiltTestPaths: Set<Set<String>> = COBUILT_TEST_PATHS, 307 private val alwaysBuildIfExists: Set<String> = ALWAYS_BUILD_IF_EXISTS, 308 private val ignoredPaths: Set<String> = IGNORED_PATHS, 309 private val changedFilesProvider: Provider<List<String>> 310 ) : AffectedModuleDetector(logger) { 311 <lambda>null312 private val allProjects by lazy { projectGraph.allProjects } 313 <lambda>null314 val affectedProjects by lazy { changedProjects + dependentProjects } 315 <lambda>null316 val changedProjects by lazy { findChangedProjects() } 317 <lambda>null318 val dependentProjects by lazy { findDependentProjects() } 319 <lambda>null320 val alwaysBuild by lazy { alwaysBuildIfExists.filter { path -> allProjects.contains(path) } } 321 322 private var unknownFiles: MutableSet<String> = mutableSetOf() 323 324 // Files tracked by git that are not expected to effect the build, thus require no consideration 325 private var ignoredFiles: MutableSet<String> = mutableSetOf() 326 <lambda>null327 val buildAll by lazy { shouldBuildAll() } 328 <lambda>null329 private val cobuiltTestProjects by lazy { lookupProjectSetsFromPaths(cobuiltTestPaths) } 330 shouldIncludenull331 override fun shouldInclude(project: String): Boolean { 332 return if (project == ":" || buildAll) { 333 true 334 } else { 335 affectedProjects.contains(project) 336 } 337 } 338 getSubsetnull339 override fun getSubset(projectPath: String): ProjectSubset { 340 return when { 341 changedProjects.contains(projectPath) -> { 342 ProjectSubset.CHANGED_PROJECTS 343 } 344 dependentProjects.contains(projectPath) -> { 345 ProjectSubset.DEPENDENT_PROJECTS 346 } 347 // projects that are only included because of buildAll 348 else -> { 349 ProjectSubset.NONE 350 } 351 } 352 } 353 354 /** 355 * Finds only the set of projects that were directly changed in the commit. This includes 356 * placeholder-tests and any modules that need to be co-built. 357 * 358 * Also populates the unknownFiles var which is used in findAffectedProjects 359 * 360 * Returns allProjects if there are no previous merge CLs, which shouldn't happen. 361 */ findChangedProjectsnull362 private fun findChangedProjects(): Set<String> { 363 val changedFiles = changedFilesProvider.getOrNull() ?: return allProjects 364 365 val changedProjects: MutableSet<String> = alwaysBuild.toMutableSet() 366 367 for (filePath in changedFiles) { 368 if (ignoredPaths.any { filePath.startsWith(it) }) { 369 ignoredFiles.add(filePath) 370 logger?.info("Ignoring file: $filePath") 371 } else { 372 val containingProject = findContainingProject(filePath) 373 if (containingProject == null) { 374 unknownFiles.add(filePath) 375 logger?.info( 376 "Couldn't find containing project for file: $filePath. Adding to " + 377 "unknownFiles." 378 ) 379 } else { 380 changedProjects.add(containingProject) 381 logger?.info( 382 "For file $filePath containing project is $containingProject. " + 383 "Adding to changedProjects." 384 ) 385 } 386 } 387 } 388 389 return changedProjects + getAffectedCobuiltProjects(changedProjects, cobuiltTestProjects) 390 } 391 392 /** 393 * Gets all dependent projects from the set of changedProjects. This doesn't include the 394 * original changedProjects. Always build is still here to ensure at least 1 thing is built 395 */ findDependentProjectsnull396 private fun findDependentProjects(): Set<String> { 397 val dependentProjects = 398 changedProjects.flatMap { dependencyTracker.findAllDependents(it) }.toSet() 399 return dependentProjects + 400 alwaysBuild + 401 getAffectedCobuiltProjects(dependentProjects, cobuiltTestProjects) 402 } 403 404 /** 405 * Determines whether we are in a state where we want to build all projects, instead of only 406 * affected ones. This occurs for buildSrc changes, as well as in situations where we determine 407 * there are no changes within our repository (e.g. prebuilts change only) 408 */ shouldBuildAllnull409 private fun shouldBuildAll(): Boolean { 410 var shouldBuildAll = false 411 // Should only trigger if there are no changedFiles and no ignored files 412 if ( 413 changedProjects.size == alwaysBuild.size && 414 unknownFiles.isEmpty() && 415 ignoredFiles.isEmpty() 416 ) { 417 shouldBuildAll = true 418 } else if (unknownFiles.isNotEmpty() && !isGithubInfraChange()) { 419 shouldBuildAll = true 420 } 421 logger?.info( 422 "unknownFiles: $unknownFiles, changedProjects: $changedProjects, buildAll: " + 423 "$shouldBuildAll" 424 ) 425 426 if (shouldBuildAll) { 427 logger?.info("Building all projects") 428 if (unknownFiles.isEmpty()) { 429 logger?.info("because no changed files were detected") 430 } else { 431 logger?.info("because one of the unknown files may affect everything in the build") 432 logger?.info( 433 """ 434 The modules detected as affected by changed files are 435 ${changedProjects + dependentProjects} 436 """ 437 .trimIndent() 438 ) 439 } 440 } 441 return shouldBuildAll 442 } 443 444 /** 445 * Returns true if all unknown changed files are contained in github setup related files. 446 * (.github, playground-common). These files will not affect aosp hence should not invalidate 447 * changed file tracking (e.g. not cause running all tests) 448 */ isGithubInfraChangenull449 private fun isGithubInfraChange(): Boolean { 450 return unknownFiles.all { it.contains(".github") || it.contains("playground-common") } 451 } 452 lookupProjectSetsFromPathsnull453 private fun lookupProjectSetsFromPaths(allSets: Set<Set<String>>): Set<Set<String>> { 454 return allSets 455 .map { setPaths -> 456 var setExists = false 457 val projectSet = HashSet<String>() 458 for (path in setPaths) { 459 if (!allProjects.contains(path)) { 460 if (setExists) { 461 throw IllegalStateException( 462 "One of the projects in the group of projects that are required " + 463 "to be built together is missing. Looked for " + 464 setPaths 465 ) 466 } 467 } else { 468 setExists = true 469 projectSet.add(path) 470 } 471 } 472 return@map projectSet 473 } 474 .toSet() 475 } 476 getAffectedCobuiltProjectsnull477 private fun getAffectedCobuiltProjects( 478 affectedProjects: Set<String>, 479 allCobuiltSets: Set<Set<String>> 480 ): Set<String> { 481 val cobuilts = mutableSetOf<String>() 482 affectedProjects.forEach { project -> 483 allCobuiltSets.forEach { cobuiltSet -> 484 if (cobuiltSet.any { project == it }) { 485 cobuilts.addAll(cobuiltSet) 486 } 487 } 488 } 489 return cobuilts 490 } 491 findContainingProjectnull492 private fun findContainingProject(filePath: String): String? { 493 return projectGraph.findContainingProject(filePath, logger).also { 494 logger?.info("search result for $filePath resulted in $it") 495 } 496 } 497 498 companion object { 499 // Project paths that we always build if they exist 500 val ALWAYS_BUILD_IF_EXISTS = 501 setOf( 502 // placeholder test project to ensure no failure due to no instrumentation. 503 // We can eventually remove if we resolve b/127819369 504 ":placeholder-tests", 505 ) 506 507 // Some tests are codependent even if their modules are not. Enable manual bundling of tests 508 val COBUILT_TEST_PATHS = 509 setOf( 510 // Link material and material-ripple 511 setOf(":compose:material:material-ripple", ":compose:material:material"), 512 setOf( 513 ":benchmark:benchmark-macro", 514 ":benchmark:integration-tests:macrobenchmark-target" 515 ), // link benchmark-macro's correctness test and its target 516 setOf( 517 ":benchmark:benchmark-macro-junit4", 518 ":benchmark:integration-tests:macrobenchmark-target" 519 ), // link benchmark-macro-junit4's correctness test and its target 520 setOf( 521 ":profileinstaller:integration-tests:profile-verification", 522 ":profileinstaller:integration-tests:profile-verification-sample", 523 ":profileinstaller:integration-tests:" + 524 "profile-verification-sample-no-initializer", 525 ":benchmark:integration-tests:baselineprofile-consumer", 526 ), 527 ) 528 529 val IGNORED_PATHS = 530 setOf( 531 "docs/", 532 "development/", 533 "playground-common/", 534 ".github/", 535 // since we only used AMD for device tests, versions do not affect test outcomes. 536 "libraryversions.toml", 537 ) 538 } 539 } 540